on main help sign in
feat(ui): add basic dialog and new UI structure
commit27fa971aff9352dc74acf834d2a5ca87800d4f8b authorAyman Bagabas <ayman.bagabas@gmail.com> dateWed 22 Oct 2025, 16:22 (1 year ago) parente159237
7 files changed+440−1
internal/cmd/root.go+4−1 internal/ui/common/common.go+9 internal/ui/dialog/dialog.go+123 internal/ui/dialog/keymap.go+57 internal/ui/dialog/quit.go+108 internal/ui/keys.go+36 internal/ui/ui.go+103
internal/cmd/root.go+4−1
@@ -18,6 +18,7 @@ import (
1818 "github.com/charmbracelet/crush/internal/db"
1919 "github.com/charmbracelet/crush/internal/event"
2020 "github.com/charmbracelet/crush/internal/tui"
21+ "github.com/charmbracelet/crush/internal/ui"
2122 "github.com/charmbracelet/crush/internal/version"
2223 "github.com/charmbracelet/fang"
2324 "github.com/charmbracelet/lipgloss/v2"
@@ -81,8 +82,10 @@ crush -y
8182 event.AppInitialized()
8283
8384 // Set up the TUI.
85+ // ui := tui.New(app)
86+ ui := ui.New(app)
8487 program := tea.NewProgram(
85− tui.New(app),
88+ ui,
8689 tea.WithContext(cmd.Context()),
8790 tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
8891
internal/ui/common/common.gonew file+9
@@ -0,0 +1,9 @@
1+package common
2+
3+import tea "github.com/charmbracelet/bubbletea/v2"
4+
5+// Model represents a common interface for UI components.
6+type Model[T any] interface {
7+ Update(msg tea.Msg) (T, tea.Cmd)
8+ View() string
9+}
internal/ui/dialog/dialog.gonew file+123
@@ -0,0 +1,123 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/ui/common"
7+ "github.com/charmbracelet/lipgloss/v2"
8+)
9+
10+// Model is a component that can be displayed on top of the UI.
11+type Model interface {
12+ common.Model[Model]
13+ ID() string
14+}
15+
16+// Overlay manages multiple dialogs as an overlay.
17+type Overlay struct {
18+ dialogs []Model
19+ keyMap KeyMap
20+}
21+
22+// NewOverlay creates a new [Overlay] instance.
23+func NewOverlay(dialogs ...Model) *Overlay {
24+ return &Overlay{
25+ dialogs: dialogs,
26+ keyMap: DefaultKeyMap(),
27+ }
28+}
29+
30+// ContainsDialog checks if a dialog with the specified ID exists.
31+func (d *Overlay) ContainsDialog(dialogID string) bool {
32+ for _, dialog := range d.dialogs {
33+ if dialog.ID() == dialogID {
34+ return true
35+ }
36+ }
37+ return false
38+}
39+
40+// AddDialog adds a new dialog to the stack.
41+func (d *Overlay) AddDialog(dialog Model) {
42+ d.dialogs = append(d.dialogs, dialog)
43+}
44+
45+// BringToFront brings the dialog with the specified ID to the front.
46+func (d *Overlay) BringToFront(dialogID string) {
47+ for i, dialog := range d.dialogs {
48+ if dialog.ID() == dialogID {
49+ // Move the dialog to the end of the slice
50+ d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
51+ d.dialogs = append(d.dialogs, dialog)
52+ return
53+ }
54+ }
55+}
56+
57+// Update handles dialog updates.
58+func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
59+ if len(d.dialogs) == 0 {
60+ return d, nil
61+ }
62+
63+ idx := len(d.dialogs) - 1 // active dialog is the last one
64+ dialog := d.dialogs[idx]
65+ switch msg := msg.(type) {
66+ case tea.KeyPressMsg:
67+ if key.Matches(msg, d.keyMap.Close) {
68+ // Close the current dialog
69+ d.removeDialog(idx)
70+ return d, nil
71+ }
72+ }
73+
74+ updatedDialog, cmd := dialog.Update(msg)
75+ if updatedDialog == nil {
76+ // Dialog requested to be closed
77+ d.removeDialog(idx)
78+ return d, cmd
79+ }
80+
81+ // Update the dialog in the stack
82+ d.dialogs[idx] = updatedDialog
83+
84+ return d, cmd
85+}
86+
87+// View implements [Model].
88+func (d *Overlay) View() string {
89+ if len(d.dialogs) == 0 {
90+ return ""
91+ }
92+
93+ // Compose all the dialogs into a single view
94+ canvas := lipgloss.NewCanvas()
95+ for _, dialog := range d.dialogs {
96+ layer := lipgloss.NewLayer(dialog.View())
97+ canvas.AddLayers(layer)
98+ }
99+
100+ return canvas.Render()
101+}
102+
103+// ShortHelp implements [help.KeyMap].
104+func (d *Overlay) ShortHelp() []key.Binding {
105+ return []key.Binding{
106+ d.keyMap.Close,
107+ }
108+}
109+
110+// FullHelp implements [help.KeyMap].
111+func (d *Overlay) FullHelp() [][]key.Binding {
112+ return [][]key.Binding{
113+ {d.keyMap.Close},
114+ }
115+}
116+
117+// removeDialog removes a dialog from the stack.
118+func (d *Overlay) removeDialog(idx int) {
119+ if idx < 0 || idx >= len(d.dialogs) {
120+ return
121+ }
122+ d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
123+}
internal/ui/dialog/keymap.gonew file+57
@@ -0,0 +1,57 @@
1+package dialog
2+
3+import "github.com/charmbracelet/bubbles/v2/key"
4+
5+// KeyMap defines key bindings for dialogs.
6+type KeyMap struct {
7+ Close key.Binding
8+}
9+
10+// DefaultKeyMap returns the default key bindings for dialogs.
11+func DefaultKeyMap() KeyMap {
12+ return KeyMap{
13+ Close: key.NewBinding(
14+ key.WithKeys("esc", "alt+esc"),
15+ ),
16+ }
17+}
18+
19+// QuitKeyMap represents key bindings for the quit dialog.
20+type QuitKeyMap struct {
21+ LeftRight,
22+ EnterSpace,
23+ Yes,
24+ No,
25+ Tab,
26+ Close key.Binding
27+}
28+
29+// DefaultQuitKeyMap returns the default key bindings for the quit dialog.
30+func DefaultQuitKeyMap() QuitKeyMap {
31+ return QuitKeyMap{
32+ LeftRight: key.NewBinding(
33+ key.WithKeys("left", "right"),
34+ key.WithHelp("←/→", "switch options"),
35+ ),
36+ EnterSpace: key.NewBinding(
37+ key.WithKeys("enter", " "),
38+ key.WithHelp("enter/space", "confirm"),
39+ ),
40+ Yes: key.NewBinding(
41+ key.WithKeys("y", "Y", "ctrl+c"),
42+ key.WithHelp("y/Y/ctrl+c", "yes"),
43+ ),
44+ No: key.NewBinding(
45+ key.WithKeys("n", "N"),
46+ key.WithHelp("n/N", "no"),
47+ ),
48+ Tab: key.NewBinding(
49+ key.WithKeys("tab"),
50+ key.WithHelp("tab", "switch options"),
51+ ),
52+ Close: key.NewBinding(
53+ key.WithKeys("esc", "alt+esc"),
54+ key.WithHelp("esc", "cancel"),
55+ ),
56+ }
57+}
internal/ui/dialog/quit.gonew file+108
@@ -0,0 +1,108 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/lipgloss/v2"
7+)
8+
9+// Quit represents a confirmation dialog for quitting the application.
10+type Quit struct {
11+ keyMap QuitKeyMap
12+ selectedNo bool // true if "No" button is selected
13+}
14+
15+// NewQuit creates a new quit confirmation dialog.
16+func NewQuit() *Quit {
17+ q := &Quit{
18+ keyMap: DefaultQuitKeyMap(),
19+ }
20+ return q
21+}
22+
23+// ID implements [Model].
24+func (*Quit) ID() string {
25+ return "quit"
26+}
27+
28+// Update implements [Model].
29+func (q *Quit) Update(msg tea.Msg) (Model, tea.Cmd) {
30+ switch msg := msg.(type) {
31+ case tea.KeyPressMsg:
32+ switch {
33+ case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
34+ q.selectedNo = !q.selectedNo
35+ return q, nil
36+ case key.Matches(msg, q.keyMap.EnterSpace):
37+ if !q.selectedNo {
38+ return q, tea.Quit
39+ }
40+ return nil, nil
41+ case key.Matches(msg, q.keyMap.Yes):
42+ return q, tea.Quit
43+ case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
44+ return nil, nil
45+ }
46+ }
47+
48+ return q, nil
49+}
50+
51+// View implements [Model].
52+func (q *Quit) View() string {
53+ const question = "Are you sure you want to quit?"
54+
55+ baseStyle := lipgloss.NewStyle()
56+ yesStyle := lipgloss.NewStyle()
57+ noStyle := yesStyle
58+
59+ if q.selectedNo {
60+ noStyle = noStyle.Foreground(lipgloss.Color("15")).Background(lipgloss.Color("15"))
61+ yesStyle = yesStyle.Background(lipgloss.Color("15"))
62+ } else {
63+ yesStyle = yesStyle.Foreground(lipgloss.Color("15")).Background(lipgloss.Color("15"))
64+ noStyle = noStyle.Background(lipgloss.Color("15"))
65+ }
66+
67+ const horizontalPadding = 3
68+ yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") +
69+ yesStyle.PaddingRight(horizontalPadding).Render("ep!")
70+ noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") +
71+ noStyle.PaddingRight(horizontalPadding).Render("ope")
72+
73+ buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
74+ lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton),
75+ )
76+
77+ content := baseStyle.Render(
78+ lipgloss.JoinVertical(
79+ lipgloss.Center,
80+ question,
81+ "",
82+ buttons,
83+ ),
84+ )
85+
86+ quitDialogStyle := baseStyle.
87+ Padding(1, 2).
88+ Border(lipgloss.RoundedBorder()).
89+ BorderForeground(lipgloss.Color("15"))
90+
91+ return quitDialogStyle.Render(content)
92+}
93+
94+// ShortHelp implements [help.KeyMap].
95+func (q *Quit) ShortHelp() []key.Binding {
96+ return []key.Binding{
97+ q.keyMap.LeftRight,
98+ q.keyMap.EnterSpace,
99+ }
100+}
101+
102+// FullHelp implements [help.KeyMap].
103+func (q *Quit) FullHelp() [][]key.Binding {
104+ return [][]key.Binding{
105+ {q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No},
106+ {q.keyMap.Tab, q.keyMap.Close},
107+ }
108+}
internal/ui/keys.gonew file+36
@@ -0,0 +1,36 @@
1+package ui
2+
3+import "github.com/charmbracelet/bubbles/v2/key"
4+
5+type KeyMap struct {
6+ Quit key.Binding
7+ Help key.Binding
8+ Commands key.Binding
9+ Suspend key.Binding
10+ Sessions key.Binding
11+}
12+
13+func DefaultKeyMap() KeyMap {
14+ return KeyMap{
15+ Quit: key.NewBinding(
16+ key.WithKeys("ctrl+c"),
17+ key.WithHelp("ctrl+c", "quit"),
18+ ),
19+ Help: key.NewBinding(
20+ key.WithKeys("ctrl+g"),
21+ key.WithHelp("ctrl+g", "more"),
22+ ),
23+ Commands: key.NewBinding(
24+ key.WithKeys("ctrl+p"),
25+ key.WithHelp("ctrl+p", "commands"),
26+ ),
27+ Suspend: key.NewBinding(
28+ key.WithKeys("ctrl+z"),
29+ key.WithHelp("ctrl+z", "suspend"),
30+ ),
31+ Sessions: key.NewBinding(
32+ key.WithKeys("ctrl+s"),
33+ key.WithHelp("ctrl+s", "sessions"),
34+ ),
35+ }
36+}
internal/ui/ui.gonew file+103
@@ -0,0 +1,103 @@
1+package ui
2+
3+import (
4+ "image"
5+
6+ "github.com/charmbracelet/bubbles/v2/key"
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/app"
9+ "github.com/charmbracelet/crush/internal/ui/dialog"
10+ "github.com/charmbracelet/lipgloss/v2"
11+ uv "github.com/charmbracelet/ultraviolet"
12+)
13+
14+type uiState uint8
15+
16+const (
17+ uiStateMain uiState = iota
18+)
19+
20+type Model struct {
21+ app *app.App
22+ width, height int
23+ state uiState
24+
25+ keyMap KeyMap
26+
27+ dialog *dialog.Overlay
28+}
29+
30+func New(app *app.App) *Model {
31+ return &Model{
32+ app: app,
33+ dialog: dialog.NewOverlay(),
34+ keyMap: DefaultKeyMap(),
35+ }
36+}
37+
38+func (m *Model) Init() tea.Cmd {
39+ return nil
40+}
41+
42+func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
43+ var cmds []tea.Cmd
44+ switch msg := msg.(type) {
45+ case tea.WindowSizeMsg:
46+ m.width = msg.Width
47+ m.height = msg.Height
48+ case tea.KeyPressMsg:
49+ switch m.state {
50+ case uiStateMain:
51+ switch {
52+ case key.Matches(msg, m.keyMap.Quit):
53+ quitDialog := dialog.NewQuit()
54+ if !m.dialog.ContainsDialog(quitDialog.ID()) {
55+ m.dialog.AddDialog(quitDialog)
56+ return m, nil
57+ }
58+ }
59+ }
60+ }
61+
62+ updatedDialog, cmd := m.dialog.Update(msg)
63+ m.dialog = updatedDialog
64+ if cmd != nil {
65+ cmds = append(cmds, cmd)
66+ }
67+
68+ return m, tea.Batch(cmds...)
69+}
70+
71+func (m *Model) View() tea.View {
72+ var v tea.View
73+
74+ // The screen area we're working with
75+ area := image.Rect(0, 0, m.width, m.height)
76+ layers := []*lipgloss.Layer{}
77+
78+ if dialogView := m.dialog.View(); dialogView != "" {
79+ dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
80+ dialogArea := centerRect(area, dialogWidth, dialogHeight)
81+ layers = append(layers,
82+ lipgloss.NewLayer(dialogView).
83+ X(dialogArea.Min.X).
84+ Y(dialogArea.Min.Y),
85+ )
86+ }
87+
88+ v.Layer = lipgloss.NewCanvas(layers...)
89+
90+ return v
91+}
92+
93+// centerRect returns a new [Rectangle] centered within the given area with the
94+// specified width and height.
95+func centerRect(area uv.Rectangle, width, height int) uv.Rectangle {
96+ centerX := area.Min.X + area.Dx()/2
97+ centerY := area.Min.Y + area.Dy()/2
98+ minX := centerX - width/2
99+ minY := centerY - height/2
100+ maxX := minX + width
101+ maxY := minY + height
102+ return image.Rect(minX, minY, maxX, maxY)
103+}