bubblepicker

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: May 1, 2026 License: MIT Imports: 8 Imported by: 0

README

bubble-color-picker

A Bubble Tea color picker component for terminal UIs. Pick colors with keyboard and mouse; get a hex value back for use with lipgloss or your theme config.

Features

  • HSL picker: Hue bar + saturation/lightness grid
  • Keyboard-first: Tab / Shift+Tab cycle focus (presets row if configured, then hue bar, then S/L grid). Arrows (or hjkl) adjust values; Enter confirms; Esc cancels.
  • Mouse (zone-based): Move the mouse over the hue bar or grid to set H or S/L; release the left button on the grid to accept. Uses bubblezone for reliable hit-testing—no coordinate math.
  • Controlled component API: New(...Option) with WithInitialColor, WithPresets, WithStyle, WithAutoDismiss. Selection is delivered as ColorChangedMsg (a tea.Cmd); ColorChosenMsg remains a type alias for compatibility.

Installation

go get github.com/madicen/bubble-color-picker

The Go import path is github.com/madicen/bubble-color-picker (package name bubblepicker).

Examples

Example Description
simple Picker is the entire app.
swatch 2×2 grid of color swatches; click any to open the modal picker.
modal Two color boxes; Tab + Enter or click to open the picker over the main view.
Screenshots

(Generated by the VHS tapes in vhs/. Run them locally or use the Generate Screenshots workflow.)

Simple — picker as the whole app

Simple example

Swatch — click to open modal picker

Swatch example

Modal — picker over the main view

Modal example

Generating screenshots

Screenshots are generated with VHS. From the bubblepicker directory:

# Install VHS (see https://github.com/charmbracelet/vhs#installation)
make screenshots

The Generate Screenshots workflow (manual or after release) runs these tapes in CI and uploads the images as artifacts. If bubblepicker is used inside another repo (e.g. as a subdirectory), run the same commands with that repo’s working directory set to bubblepicker, or add a job that uses working-directory: bubblepicker.

Running the examples

From the bubblepicker directory:

go run ./examples/simple   # Picker is the whole app
go run ./examples/swatch   # 2×2 swatches; click to open modal
go run ./examples/modal    # Two boxes; Tab+Enter or click to open

Enable mouse with tea.WithMouseAllMotion() so the picker receives motion and click events.


Quick start (picker only)

For a minimal app that is just the picker:

package main

import (
	tea "github.com/charmbracelet/bubbletea"
	zone "github.com/lrstanley/bubblezone"
	bubblepicker "github.com/madicen/bubble-color-picker"
)

func main() {
	zm := zone.New()
	picker := bubblepicker.New(bubblepicker.WithInitialColor("#7E00AF"))
	picker.SetZoneManager(zm)
	app := &model{picker: picker, zm: zm}
	p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseAllMotion())
	model, _ := p.Run()
	_ = model
}

type model struct {
	picker bubblepicker.Model
	zm     *zone.Manager
}

func (m *model) Init() tea.Cmd { return nil }

func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg.(type) {
	case bubblepicker.ColorChangedMsg:
		return m, tea.Quit
	case bubblepicker.ColorCanceledMsg:
		return m, tea.Quit
	}
	updated, cmd := m.picker.Update(msg)
	m.picker = updated.(bubblepicker.Model)
	return m, cmd
}

func (m *model) View() string {
	return m.zm.Scan(m.picker.View())
}

Mouse interaction (hover to change, release on grid to accept) only works when:

  1. You set a zone manager on the picker with picker.SetZoneManager(zm).
  2. You wrap the picker’s view with zm.Scan(...) so zones are registered.

SwatchPicker (modal from a color square)

SwatchPicker is a small color square that opens the full modal picker when clicked. Overlay positioning and mouse handling are done for you.

  1. Create a swatch: swatch := bubblepicker.NewSwatchPicker("#7E00AF", "Primary")
  2. Give the app a zone manager and set it on each swatch: swatch.SetZoneManager(zm) (so the picker uses zones when open).
  3. In View: build your main view with each swatch wrapped in zm.Mark("swatch-0", "Color 1: "+swatch.SwatchView()) (or similar), call swatch.SetBounds(row, col, w, h) before overlays, then overlay and scan: return zm.Scan(swatch.ViewWithOverlay(mainView, width, height)).
  4. In Update: when no modal is open and you get a left press, use zm.Get("swatch-i").InBounds(msg) to find which swatch was clicked and forward the message only to that swatch; when a modal is open, forward all non–WindowSize messages to the open swatch. On ColorChangedMsg / ColorChosenMsg, the swatch already has the new color.

See examples/swatch for a full 2×2 grid.

Embedding SwatchPicker in an app that uses bubblezone

When the SwatchPicker lives inside a tab or submodel of a larger app (e.g. a settings tab with multiple sub-tabs and zone-based mouse), follow these steps so the picker opens, receives input, and closes correctly.

  1. Opening the swatch
    Bubblezone delivers zone messages on release, but the swatch opens on press. So when your tab (or root) receives a mouse press (tea.MouseActionPress), check whether the press is inside the swatch zone—e.g. zm.Get(zoneID).InBounds(msg)—and, if so, forward that press to the swatch. If you only forward zone/click messages (which carry the release), the swatch will not open on that click.

  2. When the picker is open
    Forward all non–WindowSizeMsg messages (keys, mouse, etc.) to the open swatch so the picker receives input. Use swatch.Open() to know when the modal is active and route messages only to that swatch.

  3. Closing the picker
    ColorChangedMsg (same shape as legacy ColorChosenMsg) and ColorCanceledMsg are returned as tea.Cmd and are delivered to your root (or top-level) model in a later Update cycle. The root must handle them and forward to the submodel that owns the swatch (e.g. the settings tab), which then calls themeModel.Update(msg) so the swatch can close and update. If the root does not handle these message types, they are dropped and the picker never closes.

  4. Same-click release
    The library ignores the first left-button release after the swatch opens (the release of the same click that opened the modal), so that release does not confirm the color. You do not need to track or drop that release in the host.


Modal use (picker on top of another view)

For full control (e.g. multiple color boxes, Tab+Enter), use bubble-overlay and a zone manager for the main view and the picker. When the modal is open:

  • Set the zone manager on the picker: picker.SetZoneManager(zm).
  • Render with zm.Scan(viewWithModal()) so picker zones (hue bar, grid) are registered.
  • Forward raw mouse events to the picker (no coordinate conversion); bubblezone uses screen positions.

Sizing: use lipgloss.Size(modalView) for modal width/height (same as bubble-overlay’s helpers and OverlayStack), then pass your clamped top/left into overlay.OverlayView. On recent bubble-overlay, you can alternatively use overlay.Fixed(top, left).Origin(...) from the same Placement API as OverlayStack. Recent releases improve ANSI compositing (grapheme-aware width, pen reset before the modal) for correct lipgloss alignment.

For dimming the main view or stacked modals, see bubble-overlay’s DimSurface, OverlayConfig, and OverlayStack.

See examples/modal for two color boxes and overlay logic.


Configuration (controlled component)

Build the picker with New(...Option):

Option Purpose
WithInitialColor(hex string) Starting HSL from hex ("#ff0000" or empty → default red).
WithPresets([]string) Brand swatches shown above the hue bar. Tab to the preset row, ←/→ to move, Enter to confirm that hex (or click with zones). Invalid hex entries are skipped.
WithStyle(s lipgloss.Style) Outer frame (border, padding). Without this, the picker uses its default double border tinted by the current color.
WithAutoDismiss(bool) When true, ColorChangedMsg includes Dismiss: true so the host can remove the picker from the layout immediately after a selection.

Example:

picker := bubblepicker.New(
	bubblepicker.WithInitialColor(cfg.Primary),
	bubblepicker.WithPresets([]string{"#7E00AF", "#00AF7E", "#241", "#fff"}),
	bubblepicker.WithAutoDismiss(true),
)

API

Picker
  • New(opts ...Option) Model — Construct with zero or more options; defaults match the original single-purpose picker.
  • Value() string — Current color as hex (e.g. "#7E00AF").
  • View() string — Renders the picker (default frame: double border tinted by current value unless WithStyle is used).
  • ViewSize() (width, height int) — Rendered size in cells (including border). Use for overlay positioning.
  • SetZoneManager(zm *zone.Manager) — Enable zone-based mouse (hue bar, grid, and preset strip when present). Call from the same app that runs zone.Scan() on the view.
  • ColorChangedMsgstruct { Color string; Dismiss bool }. Emitted when the user confirms (Enter, preset Enter, or mouse release on grid). Handle in Update; check Dismiss when using WithAutoDismiss(true).
  • ColorChosenMsg — Type alias of ColorChangedMsg for existing code.
  • ColorCanceledMsg — Sent when the user presses Esc.
SwatchPicker
  • NewSwatchPicker(initialColor, label string) *SwatchPicker — Create a swatch; click opens the modal picker.
  • SwatchView() string — The swatch UI (color cell + ▼) to embed in your main view.
  • Size() (width, height int) — Swatch size in cells; use for layout and SetBounds.
  • SetBounds(row, col, w, h int) — Where the swatch is drawn (0-based). Call before ViewWithOverlay. row and col are in full-view coordinates (the complete string you pass to Scan()); if the swatch is inside a sub-view (e.g. a panel), add the sub-view’s starting line index to the local row.
  • ViewWithOverlay(mainView, viewWidth, viewHeight int) string — Returns the view (overlays the modal when open).
  • SetZoneManager(zm *zone.Manager) — Use zone-based mouse when the picker is open; host must run zone.Scan() on the view.
  • Update(tea.Msg) (*SwatchPicker, tea.Cmd) — Forward all messages; assign the returned pointer back.
  • SetColor(string) / Color() string — Set or read the current color.
  • Open() bool — True when the picker modal is open. When true, route input only to that swatch.
  • SetFocused(bool) / Focused() bool — For keyboard-only UIs: highlight the arrow (▼).

Use in jj-tui (or any TUI)

Embed bubblepicker.Model or SwatchPicker in your settings screen. On ColorChangedMsg, write msg.Color to your config (e.g. primary, background) and re-render your lipgloss styles.


License

MIT

Documentation

Index

Constants

View Source
const (
	ZoneHueBar  = "picker-hue"
	ZoneGrid    = "picker-grid"
	ZonePresets = "picker-presets"
)

Variables

This section is empty.

Functions

func HSLToRGB

func HSLToRGB(h, s, l float64) (r, g, b float64)

HSLToRGB converts HSL to RGB (all in 0-1 or H 0-360).

func MouseToModalCoords

func MouseToModalCoords(screenX, screenY, overlayLeft, overlayTop int) (relX, relY int)

MouseToModalCoords converts normalized Bubble Tea screen coords (0-based X and Y, same as overlay.CellInModal) to coordinates relative to the modal’s top-left cell, in the form the picker’s zone handlers expect (contentCol = relX-1, contentRow = relY-1 after Pos-style offsets).

func RGBToHSL

func RGBToHSL(r, g, b float64) (h, s, l float64)

RGBToHSL converts RGB (0-1) to HSL.

Types

type ColorCanceledMsg

type ColorCanceledMsg struct{}

ColorCanceledMsg is sent when the user cancels (Esc).

type ColorChangedMsg added in v0.1.0

type ColorChangedMsg struct {
	Color   string // Hex, e.g. "#rrggbb"
	Dismiss bool   // When true, host should remove the picker from layout (auto-dismiss mode).
}

ColorChangedMsg is sent when the user confirms a color (Enter, or mouse release on the grid). Handle it at your root or delegated model; Dismiss is true when WithAutoDismiss(true) was used.

type ColorChosenMsg

type ColorChosenMsg = ColorChangedMsg

ColorChosenMsg is a type alias for ColorChangedMsg for backward compatibility.

type Config added in v0.1.0

type Config struct {
	InitialColor string
	Presets      []string
	FrameStyle   lipgloss.Style
	CustomFrame  bool
	AutoDismiss  bool
}

Config holds static options for a picker built with New.

type Focus

type Focus int

Focus indicates which part of the picker is focused for keyboard input (Tab cycles).

const (
	// FocusPresets is used only when the model was built with WithPresets.
	FocusPresets Focus = iota
	FocusHueBar
	FocusGrid
)

type HSL

type HSL struct {
	H, S, L float64
}

HSL holds hue (0-360), saturation (0-100), lightness (0-100).

func HexToHSL

func HexToHSL(hex string) (HSL, error)

HexToHSL parses a hex color "#rrggbb" or "#rgb" and returns HSL.

func (HSL) Clamp

func (c HSL) Clamp() HSL

Clamp keeps H in [0,360), S and L in [0,100].

func (HSL) ToHex

func (c HSL) ToHex() string

ToHex returns the color as "#rrggbb".

type Model

type Model struct {
	HSL   HSL
	Focus Focus
	// contains filtered or unexported fields
}

Model is the color picker state. It implements tea.Model.

func New

func New(opts ...Option) Model

New builds a picker from functional options. With no options, behavior matches the legacy default (red, no presets, standard frame, no auto-dismiss).

func (Model) Init

func (m Model) Init() tea.Cmd

Init implements tea.Model.

func (*Model) SetZoneManager

func (m *Model) SetZoneManager(zm *zone.Manager)

SetZoneManager sets the zone manager for zone-based mouse interaction.

func (Model) Update

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update implements tea.Model.

func (Model) Value

func (m Model) Value() string

Value returns the current color as hex (e.g. "#rrggbb").

func (Model) View

func (m Model) View() string

View renders the picker.

func (Model) ViewSize

func (m Model) ViewSize() (width, height int)

ViewSize returns display size (width, height in cells), including outer frame.

type Option added in v0.1.0

type Option func(*Config)

Option configures a picker via New(opts...).

func WithAutoDismiss added in v0.1.0

func WithAutoDismiss(v bool) Option

WithAutoDismiss sets whether the picker marks selections with ColorChangedMsg.Dismiss so hosts can remove the picker from the layout immediately after a choice (e.g. modal).

func WithInitialColor added in v0.1.0

func WithInitialColor(hex string) Option

WithInitialColor sets the starting HSL from a hex string (e.g. "#ff0000"). Invalid or empty hex falls back to the default red.

func WithPresets added in v0.1.0

func WithPresets(hexes []string) Option

WithPresets adds a row of brand swatches above the hue bar. Each string should be a hex color; invalid entries are skipped. Users can focus the preset row (Tab), move with ←/→, and press Enter to apply a preset to the picker.

func WithStyle added in v0.1.0

func WithStyle(s lipgloss.Style) Option

WithStyle sets the outer frame style (border, padding, margin). The picker still draws the inner hue bar and grid; BorderForeground on the frame is merged with the current color accent when using the default double border.

type RGB

type RGB struct {
	R, G, B float64
}

RGB holds red, green, blue in 0-1.

type SwatchPicker

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

SwatchPicker is a color swatch (square + optional label + hex) that opens the modal picker when clicked. Embed it in your model, set bounds, forward messages, and use ViewWithOverlay(mainView, width, height) for the view. On ColorChosenMsg, update the swatch with SetColor(msg.Color) (or store the color in your app). SetZoneManager so the picker uses zone-based mouse interaction (host must Scan the view).

func NewSwatchPicker

func NewSwatchPicker(initialColor, label string) *SwatchPicker

NewSwatchPicker returns a swatch that shows color and opens the modal picker on click. initialColor is hex (e.g. "#7E00AF"); label is optional (not shown in the minimal UI).

func (*SwatchPicker) Color

func (s *SwatchPicker) Color() string

Color returns the current color (hex).

func (*SwatchPicker) Focused

func (s *SwatchPicker) Focused() bool

Focused returns whether the swatch is currently focused.

func (*SwatchPicker) Open

func (s *SwatchPicker) Open() bool

Open returns whether the swatch's color picker modal is currently open. Use this so your app can route input only to the open swatch when a modal is active.

func (*SwatchPicker) SetBounds

func (s *SwatchPicker) SetBounds(row, col, w, h int)

SetBounds sets where the swatch is drawn (0-based row, col) and its size (w, h). If w or h is 0, Size() is used for that dimension. Call this before ViewWithOverlay so the modal is centered on the swatch and clicks are detected correctly.

func (*SwatchPicker) SetColor

func (s *SwatchPicker) SetColor(c string)

SetColor sets the current color (hex). Call this when you receive ColorChosenMsg.

func (*SwatchPicker) SetFocused

func (s *SwatchPicker) SetFocused(f bool)

SetFocused sets whether the swatch is focused (e.g. for keyboard-only navigation). When true, the picker arrow (▼) is rendered in a brighter color.

func (*SwatchPicker) SetPickerOptions added in v0.1.1

func (s *SwatchPicker) SetPickerOptions(opts ...Option)

SetPickerOptions configures extra bubblepicker.New options used whenever the modal opens. WithInitialColor is always applied from the swatch's current color first; these append after it. Typical use: WithPresets, WithAutoDismiss.

func (*SwatchPicker) SetZoneManager

func (s *SwatchPicker) SetZoneManager(zm *zone.Manager)

SetZoneManager sets the zone manager for the picker. When set, the picker uses zone-based mouse interaction (click hue bar / grid / Accept) and receives raw screen mouse events; the host must run zone.Scan() on the view that contains the overlay.

func (*SwatchPicker) Size

func (s *SwatchPicker) Size() (width, height int)

Size returns the display size (width, height in cells) of the swatch: 2 wide (color + symbol), 1 high. Use this when building your layout and when calling SetBounds.

func (*SwatchPicker) SwatchView

func (s *SwatchPicker) SwatchView() string

SwatchView returns the swatch as a single line: one cell of color plus the picker symbol (▼). No border. When focused (e.g. for keyboard nav), the arrow is highlighted.

func (*SwatchPicker) Update

func (s *SwatchPicker) Update(msg tea.Msg) (*SwatchPicker, tea.Cmd)

Update handles messages. Forward all tea.Msg to it. When the user picks a color, you'll receive ColorChosenMsg; call SetColor(msg.Color) and assign the returned model back. When the picker is open, Update forwards to the picker with the correct mouse offset.

func (*SwatchPicker) ViewWithOverlay

func (s *SwatchPicker) ViewWithOverlay(mainView string, viewWidth, viewHeight int) string

ViewWithOverlay returns the view to display. If the picker is open, it overlays the modal on mainView. It stores overlay position and dimensions on the receiver so Update can use them for mouse offset—no need to reassign the return value.

return app.swatch.ViewWithOverlay(mainView, width, height)

Directories

Path Synopsis
examples
modal command
Modal example: picker appears as an overlay on top of the main view, centered on the color box you clicked or selected.
Modal example: picker appears as an overlay on top of the main view, centered on the color box you clicked or selected.
simple command
Simple example: the picker is the entire app.
Simple example: the picker is the entire app.
swatch command
Swatch example: 2×2 grid of color swatches that open the full modal picker on click.
Swatch example: 2×2 grid of color swatches that open the full modal picker on click.

Jump to

Keyboard shortcuts

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