zone

package module
v0.0.0-...-b48c55a Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 14, 2024 License: MIT Imports: 9 Imported by: 101

README

🔗 Table of Contents

❌ Problem

BubbleTea and lipgloss allow you to build extremely fast terminal interfaces, in a semantic and scalable way. Through abstracting layout, colors, events, and more, it's very easy to build a user-friendly application. BubbleTea also supports mouse events, either through the "basic" mouse events, like MouseButtonLeft, MouseButtonRight, MouseButtonWheelUp and MouseButtonWheelDown (and more), or through full motion tracking, allowing hover and mouse movement tracking.

This works great for a single-component application, where the state is managed in one location. However, when you start expanding your application, where components have various children, and those children have children, calculating mouse events like MouseButtonLeft and MouseButtonRight and determining which component was clicked becomes complicated, and rather tedious.

✔ Solution

BubbleZone is one solution to this problem. BubbleZone allows you to wrap your components in zero-printable-width (to not impact lipgloss.Width() calculations) identifiers. Additionally, there is a scan method that wraps the entire application, stores the offsets of those identifiers as zones, and then removes them from the resulting output.

Any time there is a mouse event, pass it down to all children, thus allowing you to easily check if the event is within the bounds of the components zone. This makes it very simple to do things like focusing on various components, clicking "buttons", and more. Take a look at this example, where I didn't have to calculate where the mouse was being clicked, and which component was under the mouse:

bubblezone example

✨ Features

  • ✔ It's fast -- given it has to process this information for every render, I tried to focus on performance where possible. If you see where improvements can be made, let me know!
  • ✔ It doesn't impact width calculations when using lipgloss.Width() (if you're using len() it will).
  • ✔ It's simple -- easily determine offset or if an event was within the bounds of a zone.
  • ✔ Want the mouse event position relative to the component? Easy!
  • ✔ Provides an optional global manager, when you have full access to all components, so you don't have to inject it as a dependency to all components.

⚙ Usage

go get -u github.com/lrstanley/bubblezone@latest

BubbleZone supports either a global zone manager (initialized via NewGlobal()), or non-global (via New()). Using the global zone manager, simply use zone.<method>. The below examples will use the global manager.

Initialize the zone manager:

package main

import (
	// [...]
	zone "github.com/lrstanley/bubblezone"
)


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.
}

Ensure the mouse is enabled and the program is running in alt screen mode (i.e. full window mode).

func main() {
	// [...]
	p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
	// [...]
}

In your root model, wrap your View() output in zone.Scan(), which will register and monitor all zones, including stripping the ANSI sequences injected by zone.Mark().

func (r app) View() string {
	// [...]
	return zone.Scan(r.someStyle.Render(generatedChildViews))
}

In your children models View() method, use zone.Mark() to wrap the area you want to mark as a zone. Make sure you give the zone a unique ID (see also: tips: overlapping markers):

func (m model) View() string {
	// [...]
	buttons := lipgloss.JoinHorizontal(
		lipgloss.Top,
		zone.Mark("confirm", okButton),
		zone.Mark("cancel", cancelButton),
	)
	return m.someStyle.Render(buttons)
}

In your children models Update() method, use zone.Get(<id>).InBounds(mouseMsg) to check if the mouse event was in the bounds of the zone:

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	// [...]
	case tea.MouseMsg:
		if msg.Action != tea.MouseActionRelease || msg.Button != tea.MouseButtonLeft {
			return m, nil
		}

		if zone.Get("confirm").InBounds(msg) {
			// Do something if it's in bounds, e.g. toggling a model flag to let
			// View() know to change its highlight colors.
			m.active = "confirm"
		} else if zone.Get("cancel").InBounds(msg) {
			m.active = "cancel"
		}

		// x, y := zone.Get("confirm").Pos() can be used to get the relative
		// coordinates within the zone. Useful if you need to move a cursor in a
		// input box as an example.

		return m, nil
	}
	return m, nil
}

... and that's it!


👏 Examples

List example

  • All titles are marked as a unique zone, and upon left click, that item is focused.
  • Example source.

list-default example

Lipgloss full example

  • All items are marked as a unique zone (uses NewPrefix() as well).
  • Child models are used, and the resulting mouse events are passed down to each model.
  • Example source.

full-lipgloss example


📝 Tips

Below are a couple of tips to ensure you have the best experience using BubbleZone.

Overlapping markers

To prevent overlapping marker ID's in child components, use NewPrefix() which will generate a guaranteed-unique prefix you can use in combination with your regular IDs.

Use lipgloss.Width

Use lipgloss.Width() for width measurements, rather than len() or similar. BubbleZone has been specifically designed so that markers will be ignored by lipgloss.Width() (in addition to this being the recommended width checking method even if you're not using BubbleZone, as len() breaks with fg/bg colors, and other control characters).

MaxHeight and MaxWidth

MaxHeight() and MaxWidth() do a hard-trim of characters to enforce a specific height and width. As such, if a child component is wrapped in a zone, and overlaps the maximum height/width, the zone will break, and standard bounds checks will not work. Due to this, it is recommended to ensure MaxHeight and MaxWidth() are only enforcing limits that should already be set by normal height/width limits on your components (i.e. just don't exceed the max viewport dimensions 😅).

Only scan at the root model

Make sure zone.Scan() is only used at the root level model, it will likely not work as you intend it in any other situation.

Organic shapes

BubbleZones InBounds() checks calculate bounds based on a box region. For example, if you have a model that generates a large circle, make sure the zone is properly padded (e.g. lipgloss.Place() or similar), to capture the entire circle. Though note that because it checks for the entire box, a mouse event will still be considered in bounds if the outer corners outside of the circle are clicked.

Example:

bounding box


🙋♂ Support & Assistance

  • ❤ Please review the Code of Conduct for guidelines on ensuring everyone has the best experience interacting with the community.
  • 🙋♂ Take a look at the support document on guidelines for tips on how to ask the right questions.
  • 🐞 For all features/bugs/issues/questions/etc, head over here.

🤝 Contributing

  • ❤ Please review the Code of Conduct for guidelines on ensuring everyone has the best experience interacting with the community.
  • 📋 Please review the contributing doc for submitting issues/a guide on submitting pull requests and helping out.
  • 🗝 For anything security related, please review this repositories security policy.

⚖ License

MIT License

Copyright (c) 2022 Liam Stanley <liam@liam.sh>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Also located here

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AnyInBounds

func AnyInBounds(model tea.Model, mouse tea.MouseMsg)

AnyInBounds sends a MsgZoneInBounds message to the provided model for each zone that is in the bounds of the provided mouse event. The results of the call to Update() are discarded.

Note that if multiple zones are within bounds, each one will be sent as an event in alphabetical sorted order of the ID.

func AnyInBoundsAndUpdate

func AnyInBoundsAndUpdate(model tea.Model, mouse tea.MouseMsg) (tea.Model, tea.Cmd)

AnyInBoundsAndUpdate is the same as AnyInBounds; except the results of the calls to Update() are carried through and returned.

The tea.Cmd's that comd off the calls to Update() are wrapped in tea.Batch().

func Clear

func Clear(id string)

Clear removes any stored zones for the given ID.

func Close

func Close()

Close stops the manager worker.

func Enabled

func Enabled() bool

Enabled returns whether the zone manager is enabled or not. When disabled, the zone manager will still parse zone information, however it will immediately drop it and remove zone markers from the resulting output.

The zone manager is enabled by default.

func Mark

func Mark(id, v string) string

Mark returns v wrapped with a start and end ANSI sequence to allow the zone manager to determine where the zone is, including its window offsets. The ANSI sequences used should be ignored by lipgloss width methods, to prevent incorrect width calculations.

When the zone manager is disabled, Mark() will return v without any changes.

func NewGlobal

func NewGlobal()

NewGlobal initializes a global manager, so you don't have to pass the manager between all components. This is primarily only useful if you have full control of the zones you want to monitor, however if developing a library using this, make sure you allow users to pass in their own manager.

The zone manager is enabled by default, and can be toggled by calling SetEnabled().

func NewPrefix

func NewPrefix() string

NewPrefix generates a zone marker ID prefix, which can help prevent overlapping zone markers between multiple components. Each call to NewPrefix() returns a new unique prefix.

Usage example:

func NewModel() tea.Model {
	return &model{
		id: zone.NewPrefix(),
	}
}

type model struct {
	id     string
	active int
	items  []string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	// [...]
	case tea.MouseMsg:
		// [...]
		for i, item := range m.items {
			if zone.Get(m.id + item.name).InBounds(msg) {
				m.active = i
				break
			}
		}
	}
	return m, nil
}

func (m model) View() string {
	return zone.Mark(m.id+"some-other-id", "rendered stuff here")
}

func Scan

func Scan(v string) string

Scan will scan the view output, searching for zone markers, returning the original view output with the zone markers stripped. Scan() should be used by the outer most model/component of your application, and not inside of a model/component child.

Scan buffers the zone info to be stored, so an immediate call to Get(id) may not return the correct information. Thus it's recommended to primarily use Get(id) for actions like mouse events, which don't occur immediately after a view shift (where the previously stored zone info might be different).

When the zone manager is disabled (via SetEnabled(false)), Scan() will return the original view output with all zone markers stripped. It will still parse the input for zone markers, as some users may cache generated views. In most situations when the zone manager is disabled (and thus Mark() returns input unchanged), Scan() will not need to do any work.

func SetEnabled

func SetEnabled(v bool)

SetEnabled enables or disables the zone manager. When disabled, the zone manager will still parse zone information, however it will immediately drop it and remove zone markers from the resulting output.

The zone manager is enabled by default.

Types

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager holds the state of the zone manager, including ID zones and zones of components.

var DefaultManager *Manager

DefaultManager is an app-wide manager. To initialize it, call NewGlobal().

func New

func New() (m *Manager)

New creates a new (non-global) zone manager. The zone manager is responsible for parsing zone information from the output of a component, and storing it for later retrieval/bounds checks.

The zone manager is enabled by default, and can be toggled by calling SetEnabled().

func (*Manager) AnyInBounds

func (m *Manager) AnyInBounds(model tea.Model, mouse tea.MouseMsg)

AnyInBounds sends a MsgZoneInBounds message to the provided model for each zone that is in the bounds of the provided mouse event. The results of the call to Update() are discarded.

Note that if multiple zones are within bounds, each one will be sent as an event in alphabetical sorted order of the ID.

func (*Manager) AnyInBoundsAndUpdate

func (m *Manager) AnyInBoundsAndUpdate(model tea.Model, mouse tea.MouseMsg) (tea.Model, tea.Cmd)

AnyInBoundsAndUpdate is the same as AnyInBounds; except the results of the calls to Update() are carried through and returned.

The tea.Cmd's that comd off the calls to Update() are wrapped in tea.Batch().

func (*Manager) Clear

func (m *Manager) Clear(id string)

Clear removes any stored zones for the given ID.

func (*Manager) Close

func (m *Manager) Close()

Close stops the manager worker.

func (*Manager) Enabled

func (m *Manager) Enabled() bool

Enabled returns whether the zone manager is enabled or not. When disabled, the zone manager will still parse zone information, however it will immediately drop it and remove zone markers from the resulting output.

The zone manager is enabled by default.

func (*Manager) Get

func (m *Manager) Get(id string) (zone *ZoneInfo)

Get returns the zone info of the given ID. If the ID is not known (yet), Get() returns nil.

func (*Manager) Mark

func (m *Manager) Mark(id, v string) string

Mark returns v wrapped with a start and end ANSI sequence to allow the zone manager to determine where the zone is, including its window offsets. The ANSI sequences used should be ignored by lipgloss width methods, to prevent incorrect width calculations.

When the zone manager is disabled, Mark() will return v without any changes.

func (*Manager) NewPrefix

func (m *Manager) NewPrefix() string

NewPrefix generates a zone marker ID prefix, which can help prevent overlapping zone markers between multiple components. Each call to NewPrefix() returns a new unique prefix.

Usage example:

func NewModel() tea.Model {
	return &model{
		id: zone.NewPrefix(),
	}
}

type model struct {
	id     string
	active int
	items  []string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	// [...]
	case tea.MouseMsg:
		// [...]
		for i, item := range m.items {
			if zone.Get(m.id + item.name).InBounds(msg) {
				m.active = i
				break
			}
		}
	}
	return m, nil
}

func (m model) View() string {
	return zone.Mark(m.id+"some-other-id", "rendered stuff here")
}

func (*Manager) Scan

func (m *Manager) Scan(v string) string

Scan will scan the view output, searching for zone markers, returning the original view output with the zone markers stripped. Scan() should be used by the outer most model/component of your application, and not inside of a model/component child.

Scan buffers the zone info to be stored, so an immediate call to Get(id) may not return the correct information. Thus it's recommended to primarily use Get(id) for actions like mouse events, which don't occur immediately after a view shift (where the previously stored zone info might be different).

When the zone manager is disabled (via SetEnabled(false)), Scan() will return the original view output with all zone markers stripped. It will still parse the input for zone markers, as some users may cache generated views. In most situations when the zone manager is disabled (and thus Mark() returns input unchanged), Scan() will not need to do any work.

func (*Manager) SetEnabled

func (m *Manager) SetEnabled(enabled bool)

SetEnabled enables or disables the zone manager. When disabled, the zone manager will still parse zone information, however it will immediately drop it and remove zone markers from the resulting output.

The zone manager is enabled by default.

type MsgZoneInBounds

type MsgZoneInBounds struct {
	Zone *ZoneInfo // The zone that is in bounds.

	Event tea.MouseMsg // The mouse event that caused the zone to be in bounds.
}

MsgZoneInBounds is a message sent when the manager detects that a zone is within bounds of a mouse event.

type ZoneInfo

type ZoneInfo struct {
	StartX int // StartX is the x coordinate of the top left cell of the zone (with 0 basis).
	StartY int // StartY is the y coordinate of the top left cell of the zone (with 0 basis).

	EndX int // EndX is the x coordinate of the bottom right cell of the zone (with 0 basis).
	EndY int // EndY is the y coordinate of the bottom right cell of the zone (with 0 basis).
	// contains filtered or unexported fields
}

ZoneInfo holds information about the start and end positions of a zone.

func Get

func Get(id string) (a *ZoneInfo)

Get returns the zone info of the given ID. If the ID is not known (yet), Get() returns nil.

func (*ZoneInfo) InBounds

func (z *ZoneInfo) InBounds(e tea.MouseMsg) bool

InBounds returns true if the mouse event was in the bounds of the zones coordinates. If the zone is not known, it returns false. It calculates this using a box between the start and end coordinates. If you're looking to check for abnormal shapes (e.g. something that might wrap a line, but can't be determined using a box), you'll likely have to implement this yourself.

func (*ZoneInfo) IsZero

func (z *ZoneInfo) IsZero() bool

IsZero returns true if the zone isn't known yet (is nil).

func (*ZoneInfo) Pos

func (z *ZoneInfo) Pos(msg tea.MouseMsg) (x, y int)

Pos returns the coordinates of the mouse event relative to the zone, with a basis of (0, 0) being the top left cell of the zone. If the zone is not known, or the mouse event is not in the bounds of the zone, this will return (-1, -1).

Directories

Path Synopsis
examples module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL