v0.31.0 bf0756b

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

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