v0.29.0 21b3150

modal.go RawHistoryFolder

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

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