gitstack

grindlemire/go-tui code browser

5.2 KB Go 186 lines 2026-04-03 · 2dcff81 raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
package tui

// Modal is a built-in component that renders a full-screen overlay.
// It supports backdrop dimming, focus trapping, and close-on-escape behavior.
type Modal struct {
	// Configuration (set via options)
	open            *State[bool]
	backdrop        string // "dim", "blank", "none"
	closeOnEscape   bool
	closeOnBackdrop bool
	trapFocus       bool
	elementOpts     []Option

	// Custom key bindings injected before the catch-all
	customKeyMap KeyMap

	// Internal state
	app              *App
	element          *Element
	previousFocusIdx int // focus index before modal opened (-1 = none)
	wasOpen          bool
}

var (
	_ Component     = (*Modal)(nil)
	_ KeyListener   = (*Modal)(nil)
	_ MouseListener = (*Modal)(nil)
	_ AppBinder     = (*Modal)(nil)
)

// NewModal creates a new Modal with the given options.
func NewModal(opts ...ModalOption) *Modal {
	m := &Modal{
		backdrop:         "dim",
		closeOnEscape:    true,
		closeOnBackdrop:  true,
		trapFocus:        true,
		previousFocusIdx: -1,
	}
	for _, opt := range opts {
		opt(m)
	}
	return m
}

// BindApp wires the modal's state to the App.
func (m *Modal) BindApp(app *App) {
	m.app = app
	if m.open != nil {
		m.open.BindApp(app)
	}
}

// Render returns the modal's element tree.
// When open, registers the element as an overlay for post-render compositing.
// When closed, returns a hidden placeholder.
func (m *Modal) Render(app *App) *Element {
	isOpen := m.open != nil && m.open.Get()

	if !isOpen {
		// Closed: return hidden overlay placeholder
		if m.wasOpen {
			// Transition from open to closed
			if m.trapFocus && m.app != nil {
				m.app.focus.ClearScope()
			}
			if m.trapFocus && m.previousFocusIdx >= 0 && m.app != nil {
				m.app.focus.setFocusIndex(m.previousFocusIdx)
				m.previousFocusIdx = -1
			}
			m.wasOpen = false
		}
		m.element = New(WithOverlay(true), WithHidden(true))
		return m.element
	}

	// Open: build the overlay container
	m.element = New(WithOverlay(true))
	for _, opt := range m.elementOpts {
		opt(m.element)
	}

	// Handle open transition: save previous focus index when trapping focus
	needsFocusInit := false
	if !m.wasOpen {
		if m.trapFocus && m.app != nil {
			m.previousFocusIdx = m.app.focus.focusedIndex()
		}
		m.wasOpen = true
		needsFocusInit = true
	}

	// Register overlay for the App's render pass.
	// needsFocusInit tells the render pass to move focus into the modal
	// on the first frame (after children are attached).
	app.registerOverlay(m.element, m.backdrop, m.trapFocus, needsFocusInit)

	return m.element
}

// KeyMap returns key bindings for the modal.
// Escape (if closeOnEscape) and Enter (activate focused element) are always
// bound. When trapFocus is true, Tab/Shift+Tab cycling and an AnyKey catch-all
// are added, blocking all unhandled keys from parent handlers. When trapFocus
// is false, unhandled keys propagate to parent components. Custom bindings
// from WithModalKeyMap are inserted before the catch-all.
func (m *Modal) KeyMap() KeyMap {
	if m.open == nil || !m.open.Get() {
		return nil
	}
	// In inline mode without alternate screen, overlays are not rendered
	// (registerOverlay silently skips them). Returning preemptive bindings
	// here would block all keyboard input with no visible modal.
	if m.app != nil && !m.app.inAlternateScreen && m.app.inlineHeight > 0 {
		return nil
	}
	var km KeyMap
	if m.closeOnEscape {
		km = append(km, OnPreemptStop(KeyEscape, func(ke KeyEvent) {
			m.open.Set(false)
		}))
	}
	if m.trapFocus && m.app != nil {
		km = append(km,
			OnPreemptStop(KeyTab, func(ke KeyEvent) {
				m.app.FocusNext()
			}),
			OnPreemptStop(KeyTab.Shift(), func(ke KeyEvent) {
				m.app.FocusPrev()
			}),
		)
	}
	// Enter activates the focused element's onActivate callback
	km = append(km, OnPreemptStop(KeyEnter, func(ke KeyEvent) {
		if m.app == nil {
			return
		}
		if focused, ok := m.app.Focused().(*Element); ok && focused != nil {
			focused.Activate()
		}
	}))
	// Custom key bindings (user-provided via WithModalKeyMap)
	km = append(km, m.customKeyMap...)
	// Catch-all: block remaining keys from reaching parent handlers.
	// Only active when trapFocus is true; with trapFocus=false, unhandled
	// keys propagate to parent components.
	if m.trapFocus {
		km = append(km, OnPreemptStop(AnyKey, func(ke KeyEvent) {}))
	}
	return km
}

// HandleMouse handles click events within the modal.
// Clicking a child with onActivate triggers it. Clicking the backdrop closes the modal.
func (m *Modal) HandleMouse(me MouseEvent) bool {
	if m.open == nil || !m.open.Get() {
		return false
	}
	if me.Action != MousePress || me.Button != MouseLeft {
		return false
	}
	if m.element == nil {
		return false
	}
	hit := m.element.ElementAt(me.X, me.Y)
	if hit == nil {
		return false
	}
	// Backdrop click (hit the overlay container itself, not a child)
	if hit == m.element {
		if m.closeOnBackdrop {
			m.open.Set(false)
		}
		return true // always consume backdrop clicks
	}
	// Check if the clicked element (or an ancestor up to the overlay) has onActivate
	for el := hit; el != nil && el != m.element; el = el.parent {
		if el.onActivate != nil {
			el.Activate()
			return true
		}
	}
	// Click landed inside the modal on non-activatable content.
	// Consume it to prevent leaking to parent handlers.
	return true
}