BubbleZone

repository·master·Indexed 21 days ago

https://github.com/lrstanley/bubblezone

A Go library for creating interactive bubble-style UI components in terminal applications. It enables developers to determine which component was clicked in complex, nested BubbleTea and Lipgloss interfaces using a Mark-Scan-Check workflow to track mouse events within defined zones.

Tokens
4.9K
Snippets
23
Records
28
Agent score
74%

What's inside bubblezone

  1. How BubbleZone works: Mark, Scan, and Check

    master

    BubbleZone solves the problem of determining which component was clicked in complex, nested BubbleTea/Lipgloss interfaces. The workflow consists of three steps:

    1. Mark: In your child components' View() methods, wrap the desired area with zone.Mark(id, content). This assigns a unique identifier to that area.
    2. Scan: In your root model's View() method, wrap the entire output in zone.Scan(). This registers all zones and strips the invisible markers from the final output so they don't affect layout.
    3. Check: In your Update() method, use zone.Get(id).InBounds(msg) to check if a mouse event (like tea.MouseReleaseMsg) occurred within the bounds of a specific zone.

    Requirements:

    • You must enable AltScreen in your BubbleTea view.
    • You must set MouseMode to tea.MouseModeCellMotion to enable mouse motion tracking.
    // 1. Mark in child View
    func (m model) View() string {
    	return zone.Mark("confirm", okButton)
    }
    
    // 2. Scan in root View
    func (r app) View() tea.View {
    	view := tea.NewView()
    	view.AltScreen = true
    	view.MouseMode = tea.MouseModeCellMotion
    	view.SetContent(zone.Scan(r.someStyle.Render(content)))
    	return view
    }
    
    // 3. Check in Update
    func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    	if msg, ok := msg.(tea.MouseReleaseMsg); ok {
    		if zone.Get("confirm").InBounds(msg) {
    			// Handle click
    		}
    	}
    	return m, nil
    }
  2. Tips for using BubbleZone

    master

    Avoid Overlapping Markers

    To prevent ID collisions in child components, use zone.NewPrefix() to generate a guaranteed-unique prefix to combine with your regular IDs.

    Use lipgloss.Width for measurements

    Always use lipgloss.Width() for width measurements instead of len(). BubbleZone is designed so that markers are ignored by lipgloss.Width(), whereas len() will include the invisible marker characters and return incorrect results.

    Be careful with MaxHeight and MaxWidth

    MaxHeight() and MaxWidth() perform a hard-trim of characters. If a child component wrapped in a zone is trimmed by these methods, the zone will break and InBounds() checks will fail. Only use these for limits that are already naturally enforced by your component dimensions.

    Organic Shapes

    InBounds() calculates bounds based on a rectangular box. If you have a non-rectangular shape (like a circle), ensure the zone is properly padded (e.g., using lipgloss.Place()) to capture the shape. Note that clicking the corners of the bounding box outside the shape will still count as being 'in bounds'.

  3. Initialize the BubbleZone manager

    master

    BubbleZone supports a global zone manager which can be initialized via zone.NewGlobal(). This allows you to access zones anywhere in your application without injecting a dependency into every component.

    If your application continues running after a UI component is closed, it is recommended to call zone.Close() to stop background workers.

    package main
    
    import (
    	// [...]
    	zone "github.com/lrstanley/bubblezone/v2"
    )
    
    func main() {
    	// [...]
    	zone.NewGlobal()
    	// If the UI will be closed at some point and the application will still run,
    	// use zone.Close() to stop all background workers:
    	// defer zone.Close()
    	//
    	// [...]
    	// Initialize your application here.
    }
  4. Initialize a new zone Manager

    master

    Use New() to create a non-global Manager instance. The manager is responsible for parsing zone information from component outputs and storing it for later retrieval or bounds checking. It is enabled by default and runs a background worker to process zone updates.

    Note: Always call Close() when the manager is no longer needed to stop the background worker.

    `m := zone.New()`
    // ... use manager
    `m.Close()`
  5. Initialize the global zone manager with NewGlobal()

    master

    To manage bubblezones across an entire application without passing a manager instance between components, call NewGlobal(). This initializes DefaultManager.

    Note for library authors: If you are developing a library, do not rely solely on the global manager. Instead, allow users to pass in their own manager instance to ensure compatibility and control.

    import "github.com/lrstanley/lrstanley/bubblezone/v2/zone"
    
    func init() {
    	zone.NewGlobal()
    }
  6. Use `zone.Scan` in the root model

    master

    To register and monitor all zones in your application, you must wrap your root model's View() output in zone.Scan(). This method strips the ANSI sequences used for marking so they do not interfere with terminal rendering.

    Note: zone.Scan() should only be used at the root level model.

    func (r app) View() tea.View {
        var view tea.View
        // Ensure that alt-screen is enabled, as bubblezone will only work in alt-screen mode.
        view.AltScreen = true
        // Enable mouse motion tracking.
        view.MouseMode = tea.MouseModeCellMotion
        // Wrap view in [zone.Scan].
        view.SetContent(zone.Scan(r.someStyle.Render(generatedChildViews)))
    	return view
    }
  7. Use `zone.Mark` to define interactive areas

    master

    In a component's View() method, use zone.Mark(id, content) to wrap the content you want to make interactive. Each zone must have a unique ID. If you are building reusable components that might be used multiple times, use zone.NewPrefix() to generate a unique prefix to avoid ID collisions.

    func (m model) View() string {
    	buttons := lipgloss.JoinHorizontal(
    		lipgloss.Top,
    		zone.Mark("confirm", okButton),
    		zone.Mark("cancel", cancelButton),
    	)
    	return m.someStyle.Render(buttons)
    }
  8. Check mouse bounds with `zone.Get(id).InBounds(msg)`

    master

    To determine if a mouse event occurred within a specific zone, use zone.Get(id).InBounds(msg). This is typically used inside the Update() method when handling mouse messages.

    Additionally, you can use zone.Get(id).Pos() to get the relative coordinates (x, y) of the mouse within that specific zone, which is useful for implementing features like text cursor movement.

    case tea.MouseReleaseMsg:
        if msg.Button != tea.MouseLeft {
            return m, nil
        }
    
        if zone.Get("confirm").InBounds(msg) {
            m.active = "confirm"
        } else if zone.Get("cancel").InBounds(msg) {
            m.active = "cancel"
        }
  9. Mark content with zone IDs using Mark()

    master

    Wrap a string with Mark(id, v string) to include start and end ANSI sequences. This allows the manager to track the zone's location and window offsets.

    Important: The ANSI sequences added by Mark() are designed to be ignored by lipgloss width methods to ensure correct layout calculations.

    // Example usage
    output := zone.Mark("my-zone-id", "content to be tracked")
  10. Manage the global manager state

    master

    The global manager can be controlled using the following functions:

    • Close(): Stops the manager worker.
    • SetEnabled(v bool): Enables or disables the zone manager. When disabled, Mark() returns the input unchanged, and Scan() strips markers from the output. The manager is enabled by default.
    • Enabled() bool: Returns whether the zone manager is currently enabled.