v0.33.2 57f21d6

modal.go RawHistoryFolder

1package input
2
3import (
4	"context"
5	"sync"
6	"time"
7
8	"github.com/bnema/dumber/internal/logging"
9)
10
11type modalTimer interface {
12	Stop() bool
13}
14
15// Mode represents the current input mode.
16type Mode int
17
18const (
19	// ModeNormal is the default mode where keys pass through to WebView.
20	ModeNormal Mode = iota
21	// ModeTab is the modal tab management mode.
22	ModeTab
23	// ModePane is the modal pane management mode.
24	ModePane
25	// ModeSession is the modal session management mode.
26	ModeSession
27	// ModeResize is the modal pane resizing mode.
28	ModeResize
29	// ModeVim is the modal Vim-style scrolling mode.
30	ModeVim
31	// ModePrefix is the tmux-style one-shot prefix mode.
32	ModePrefix
33)
34
35// String returns a human-readable mode name.
36func (m Mode) String() string {
37	switch m {
38	case ModeNormal:
39		return "normal"
40	case ModeTab:
41		return "tab"
42	case ModePane:
43		return "pane"
44	case ModeSession:
45		return "session"
46	case ModeResize:
47		return "resize"
48	case ModeVim:
49		return "vim"
50	case ModePrefix:
51		return "prefix"
52	default:
53		return "unknown"
54	}
55}
56
57// DisplayName returns the mode name for display in UI (e.g., toaster).
58func (m Mode) DisplayName() string {
59	switch m {
60	case ModeTab:
61		return "TAB MODE"
62	case ModePane:
63		return "PANE MODE"
64	case ModeSession:
65		return "SESSION MODE"
66	case ModeResize:
67		return "RESIZE MODE"
68	case ModeVim:
69		return "VIM MODE"
70	case ModePrefix:
71		return "PREFIX"
72	default:
73		return ""
74	}
75}
76
77// ModalState manages the current input mode with optional timeout.
78type ModalState struct {
79	mode     Mode
80	timeout  time.Duration
81	timer    modalTimer
82	timerGen int64 // incremented each time a timer is started; used to ignore stale callbacks
83
84	afterFunc func(time.Duration, func()) modalTimer
85
86	// Callback for mode changes (called synchronously under lock).
87	onModeChange func(from, to Mode)
88
89	// scheduleOnMainThread dispatches a function to the GTK main thread.
90	// Timer callbacks use this so that onModeChange (which may make GTK
91	// calls like switching controller phase) always runs on the GTK thread.
92	// Defaults to direct execution (suitable for tests without a GTK loop).
93	// The app sets this to a glib.IdleAdd wrapper via SetMainThreadScheduler.
94	scheduleOnMainThread func(fn func())
95
96	mu sync.RWMutex
97}
98
99// NewModalState creates a new modal state manager.
100func NewModalState(ctx context.Context) *ModalState {
101	log := logging.FromContext(ctx)
102	log.Debug().Msg("creating modal state")
103	return &ModalState{
104		mode:                 ModeNormal,
105		afterFunc:            func(d time.Duration, fn func()) modalTimer { return time.AfterFunc(d, fn) },
106		scheduleOnMainThread: func(fn func()) { fn() },
107	}
108}
109
110// EnterVimMode switches to Vim-style scrolling mode with an optional timeout.
111func (m *ModalState) EnterVimMode(ctx context.Context, timeout time.Duration) {
112	m.enterMode(ctx, ModeVim, timeout)
113}
114
115// SetMainThreadScheduler sets the function used to dispatch timer callbacks
116// to the GTK main thread. In the real app this should be a glib.IdleAdd
117// wrapper. In tests, the default (direct execution) is used.
118func (m *ModalState) SetMainThreadScheduler(fn func(func())) {
119	m.mu.Lock()
120	defer m.mu.Unlock()
121	if fn == nil {
122		return
123	}
124	m.scheduleOnMainThread = fn
125}
126
127// Mode returns the current mode (thread-safe).
128func (m *ModalState) Mode() Mode {
129	m.mu.RLock()
130	defer m.mu.RUnlock()
131	return m.mode
132}
133
134// EnterTabMode switches to tab mode with an optional timeout.
135func (m *ModalState) EnterTabMode(ctx context.Context, timeout time.Duration) {
136	m.enterMode(ctx, ModeTab, timeout)
137}
138
139// EnterPaneMode switches to pane mode with an optional timeout.
140func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
141	m.enterMode(ctx, ModePane, timeout)
142}
143
144// EnterSessionMode switches to session mode with an optional timeout.
145func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {
146	m.enterMode(ctx, ModeSession, timeout)
147}
148
149// EnterPrefixMode switches to the one-shot prefix mode with an optional timeout.
150func (m *ModalState) EnterPrefixMode(ctx context.Context, timeout time.Duration) {
151	m.enterMode(ctx, ModePrefix, timeout)
152}
153
154// EnterResizeMode switches to resize mode with an optional timeout.
155func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
156	m.enterMode(ctx, ModeResize, timeout)
157}
158
159// enterMode is the shared implementation for all mode-enter methods.
160// If already in the target mode, it resets the timeout instead.
161func (m *ModalState) enterMode(ctx context.Context, target Mode, timeout time.Duration) {
162	log := logging.FromContext(ctx)
163	m.mu.Lock()
164	defer m.mu.Unlock()
165
166	if m.mode == target {
167		m.resetTimeoutLocked(ctx, timeout)
168		return
169	}
170
171	m.cancelTimerLocked()
172	oldMode := m.mode
173	m.mode = target
174	m.timeout = timeout
175
176	if timeout > 0 {
177		m.startTimeoutLocked(ctx, timeout)
178	}
179
180	log.Debug().
181		Str("from", oldMode.String()).
182		Str("to", target.String()).
183		Dur("timeout", timeout).
184		Msg("entered " + target.String() + " mode")
185
186	if m.onModeChange != nil {
187		m.onModeChange(oldMode, target)
188	}
189}
190
191// ExitMode returns to normal mode.
192func (m *ModalState) ExitMode(ctx context.Context) {
193	log := logging.FromContext(ctx)
194	m.mu.Lock()
195	defer m.mu.Unlock()
196
197	if m.mode == ModeNormal {
198		return
199	}
200
201	m.cancelTimerLocked()
202	oldMode := m.mode
203	m.mode = ModeNormal
204	m.timeout = 0
205
206	log.Debug().
207		Str("from", oldMode.String()).
208		Str("to", "normal").
209		Msg("exited modal mode")
210
211	if m.onModeChange != nil {
212		m.onModeChange(oldMode, ModeNormal)
213	}
214}
215
216// ResetTimeout restarts the mode timeout (e.g., after a valid keystroke).
217// Does nothing if not in a modal mode or if no timeout was set.
218func (m *ModalState) ResetTimeout(ctx context.Context) {
219	m.mu.Lock()
220	defer m.mu.Unlock()
221
222	if m.mode == ModeNormal || m.timeout == 0 {
223		return
224	}
225
226	m.resetTimeoutLocked(ctx, m.timeout)
227}
228
229// SetOnModeChange sets the callback for mode changes.
230// The callback is invoked synchronously under the lock.
231func (m *ModalState) SetOnModeChange(fn func(from, to Mode)) {
232	m.mu.Lock()
233	defer m.mu.Unlock()
234	m.onModeChange = fn
235}
236
237// cancelTimerLocked stops any active timeout timer.
238// Must be called with m.mu held.
239func (m *ModalState) cancelTimerLocked() {
240	if m.timer != nil {
241		m.timer.Stop()
242		m.timer = nil
243		m.timerGen++
244	}
245}
246
247// startTimeoutLocked starts a new timeout timer that exits the mode via the
248// main thread scheduler. Timer goroutines must not call ExitMode directly
249// because onModeChange may make GTK calls (e.g., switching controller phase).
250// Must be called with m.mu held.
251func (m *ModalState) startTimeoutLocked(ctx context.Context, timeout time.Duration) {
252	m.timerGen++
253	gen := m.timerGen
254	schedule := m.scheduleOnMainThread
255	m.timer = m.afterFunc(timeout, func() {
256		schedule(func() {
257			// Ignore stale callback: time.Timer.Stop can race with the
258			// timer firing, so a canceled timer's callback may still run.
259			m.mu.RLock()
260			stale := m.timerGen != gen
261			m.mu.RUnlock()
262			if stale {
263				return
264			}
265			m.ExitMode(ctx)
266		})
267	})
268}
269
270// resetTimeoutLocked resets the timeout timer.
271// Must be called with m.mu held.
272func (m *ModalState) resetTimeoutLocked(ctx context.Context, timeout time.Duration) {
273	m.cancelTimerLocked()
274	m.timeout = timeout
275	if timeout > 0 {
276		m.startTimeoutLocked(ctx, timeout)
277	}
278}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close