1package ui
2
3import (
4 "strings"
5
6 "github.com/bnema/dumber/internal/domain/entity"
7 "github.com/bnema/dumber/internal/ui/input"
8 "github.com/bnema/dumber/internal/ui/layout"
9 "github.com/bnema/puregotk/v4/gdk"
10 "github.com/bnema/puregotk/v4/glib"
11 "github.com/bnema/puregotk/v4/gobject"
12 "github.com/bnema/puregotk/v4/graphene"
13 "github.com/bnema/puregotk/v4/gtk"
14)
15
16const (
17 legendColumnSpacing = 8
18 legendMaxWidth = 700
19 legendCompactWidth = 460
20 legendTwoColumnWidth = 650
21 legendInset = 16
22 legendHeaderSpace = 80
23 legendRefreshMs = 100
24)
25
26// modeFrame owns the per-window mode border and its non-interactive legend.
27// GTK access is restricted to the main thread, as with the rest of the UI.
28type modeFrame struct {
29 mode input.Mode
30 tabID entity.TabID
31 target layout.Widget
32 frameClass string
33 root *gtk.Box
34 border *gtk.Box
35 borderStyle cssClassTarget // border's CSS classes; a seam for tests
36 borderRect func() (x, y, width, height int, ok bool)
37 panel *gtk.Box
38 content *gtk.FlowBox
39 scroller *gtk.ScrolledWindow
40 heading *gtk.Label
41 rows map[string][]*gtk.Widget
42 keycaps map[string][]*gtk.Widget
43 pending string
44 visible bool
45 style string
46 modeClass string
47 config entity.WorkspaceStylingConfig
48 showTimer uint
49 flashTimer uint
50 generation uint64
51 placement func() (x, y, width, height int, ok bool)
52 refreshSource uint
53 lastColumns uint
54 lastPanelWidth int
55 lastMaxHeight int
56 overlay *gtk.Overlay
57 window *gtk.ApplicationWindow
58 pressInFlight bool
59 pressIdle uint
60 positionHandlerID uint
61 pulseCycle bool
62 lingering bool
63 pointerX, pointerY float64
64 pointerInside bool
65 motionController *gtk.EventControllerMotion
66 keyController *gtk.EventControllerKey
67 clickController *gtk.GestureClick
68 motionCb func(gtk.EventControllerMotion, float64, float64)
69 enterCb func(gtk.EventControllerMotion, float64, float64)
70 leaveCb func(gtk.EventControllerMotion)
71 keyCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
72 clickCb func(gtk.GestureClick, int, float64, float64)
73}
74
75func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame {
76 if overlay == nil || window == nil {
77 return nil
78 }
79 f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window}
80 f.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
81 f.root.SetHalign(gtk.AlignStartValue)
82 f.root.SetValign(gtk.AlignStartValue)
83 f.root.SetCanFocus(false)
84 f.root.SetCanTarget(false)
85 f.root.SetVisible(false)
86 f.root.AddCssClass("mode-legend-anchor")
87 f.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
88 f.panel.SetCanFocus(false)
89 f.panel.SetCanTarget(false)
90 f.panel.AddCssClass("omnibox-container")
91 f.panel.AddCssClass("mode-legend-panel")
92 f.root.Append(&f.panel.Widget)
93 title := ""
94 f.heading = gtk.NewLabel(&title)
95 f.heading.SetCanTarget(false)
96 f.heading.SetCanFocus(false)
97 f.heading.SetHalign(gtk.AlignStartValue)
98 f.heading.AddCssClass("mode-legend-title")
99 f.panel.Append(&f.heading.Widget)
100 f.initLegendContent()
101 // The border is its own overlay: inset shadows on pane containers are
102 // painted below web content and would be invisible.
103 f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
104 f.border.SetCanFocus(false)
105 f.border.SetCanTarget(false)
106 f.border.SetVisible(false)
107 f.border.AddCssClass("mode-frame-border")
108 f.borderStyle = f.border
109 overlay.AddOverlay(&f.border.Widget)
110 overlay.SetClipOverlay(&f.border.Widget, false)
111 overlay.SetMeasureOverlay(&f.border.Widget, false)
112 overlay.AddOverlay(&f.root.Widget)
113 overlay.SetClipOverlay(&f.root.Widget, false)
114 overlay.SetMeasureOverlay(&f.root.Widget, false)
115 // A single positioned overlay tracks the active pane without reparenting it.
116 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
117 placement := f.placement
118 if f.border != nil && widgetPtr == f.border.GoPointer() {
119 placement = f.borderRect
120 } else if widgetPtr != f.root.GoPointer() {
121 return false
122 }
123 if placement == nil {
124 return false
125 }
126 x, y, w, h, ok := placement()
127 if !ok {
128 return false
129 }
130 return writeOverlayAllocation(allocation, x, y, w, h)
131 }
132 f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
133 f.installDismissControllers()
134 return f
135}
136
137func (f *modeFrame) installDismissControllers() {
138 overlay := f.overlay
139 window := f.window
140 f.motionController = gtk.NewEventControllerMotion()
141 f.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
142 f.pointerX, f.pointerY, f.pointerInside = x, y, true
143 }
144 f.leaveCb = func(_ gtk.EventControllerMotion) { f.pointerInside = false }
145 f.enterCb = func(_ gtk.EventControllerMotion, x, y float64) {
146 f.pointerX, f.pointerY, f.pointerInside = x, y, true
147 }
148 f.motionController.ConnectEnter(&f.enterCb)
149 f.motionController.ConnectMotion(&f.motionCb)
150 f.motionController.ConnectLeave(&f.leaveCb)
151 overlay.AddController(&f.motionController.EventController)
152 f.keyController = gtk.NewEventControllerKey()
153 f.keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
154 f.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
155 f.dismissLinger()
156 return false
157 }
158 f.keyController.ConnectKeyPressed(&f.keyCb)
159 window.AddController(&f.keyController.EventController)
160 f.clickController = gtk.NewGestureClick()
161 f.clickController.SetButton(0)
162 f.clickController.SetPropagationPhase(gtk.PhaseCaptureValue)
163 f.clickCb = func(_ gtk.GestureClick, _ int, _, _ float64) {
164 f.dismissLinger()
165 f.pressInFlight = true
166 if f.pressIdle != 0 {
167 glib.SourceRemove(f.pressIdle)
168 }
169 cb := glib.SourceFunc(func(_ uintptr) bool {
170 f.pressInFlight = false
171 f.pressIdle = 0
172 return false
173 })
174 f.pressIdle = glib.IdleAdd(&cb, 0)
175 }
176 f.clickController.ConnectPressed(&f.clickCb)
177 window.AddController(&f.clickController.EventController)
178}
179
180func (f *modeFrame) initLegendContent() {
181 f.content = gtk.NewFlowBox()
182 f.content.SetCanFocus(false)
183 f.content.SetCanTarget(false)
184 f.content.SetOrientation(gtk.OrientationHorizontalValue)
185 f.content.SetSelectionMode(gtk.SelectionNoneValue)
186 f.content.SetMinChildrenPerLine(1)
187 f.content.SetMaxChildrenPerLine(3)
188 f.content.SetColumnSpacing(legendColumnSpacing)
189 f.content.SetRowSpacing(4)
190 f.content.AddCssClass("mode-legend-content")
191 f.scroller = gtk.NewScrolledWindow()
192 f.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
193 f.scroller.SetPropagateNaturalHeight(true)
194 f.scroller.SetHasFrame(false)
195 f.scroller.SetCanFocus(false)
196 f.scroller.SetCanTarget(false)
197 f.scroller.SetChild(&f.content.Widget)
198 f.panel.Append(&f.scroller.Widget)
199}
200
201func (f *modeFrame) destroy() {
202 if f == nil {
203 return
204 }
205 f.cancelTimers()
206 f.dismissLinger()
207 f.stopGeometryRefresh()
208 if f.pressIdle != 0 {
209 glib.SourceRemove(f.pressIdle)
210 f.pressIdle = 0
211 }
212 if f.overlay != nil {
213 f.overlay.RemoveController(&f.motionController.EventController)
214 }
215 if f.window != nil {
216 f.window.RemoveController(&f.keyController.EventController)
217 f.window.RemoveController(&f.clickController.EventController)
218 }
219 f.setTarget(nil, "")
220 if f.overlay != nil && f.positionHandlerID != 0 {
221 gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
222 f.positionHandlerID = 0
223 }
224 f.placement = nil
225 f.borderRect = nil
226 if f.overlay != nil && f.root != nil {
227 f.overlay.RemoveOverlay(&f.root.Widget)
228 }
229 if f.overlay != nil && f.border != nil {
230 f.overlay.RemoveOverlay(&f.border.Widget)
231 }
232 f.overlay = nil
233 f.window = nil
234 f.root = nil
235 f.border = nil
236 f.borderStyle = nil
237}
238
239type cssClassTarget interface {
240 AddCssClass(string)
241 RemoveCssClass(string)
242}
243
244func (f *modeFrame) setTarget(target layout.Widget, class string) {
245 if f == nil {
246 return
247 }
248 if target == f.target && class == f.frameClass {
249 return
250 }
251 if f.borderStyle != nil && f.frameClass != "" {
252 f.borderStyle.RemoveCssClass(f.frameClass)
253 for _, pulseClass := range vimPulseClasses {
254 f.borderStyle.RemoveCssClass(pulseClass)
255 }
256 }
257 f.target, f.frameClass = target, class
258 if f.borderStyle != nil && target != nil && class != "" {
259 f.borderStyle.AddCssClass(class)
260 }
261 if f.border == nil {
262 return
263 }
264 if target != nil && class != "" {
265 f.startGeometryRefresh()
266 return
267 }
268 f.stopGeometryRefresh()
269 f.borderRect = nil
270 f.border.SetVisible(false)
271}
272
273var vimPulseClasses = []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"}
274
275func (f *modeFrame) pulse(fast bool) {
276 if f == nil || f.mode != input.ModeVim || f.target == nil || f.borderStyle == nil {
277 return
278 }
279 for _, class := range vimPulseClasses {
280 f.borderStyle.RemoveCssClass(class)
281 }
282 class := "vim-mode-pulse"
283 if fast {
284 class = "vim-mode-pulse-fast"
285 }
286 f.borderStyle.AddCssClass(class)
287 cycle := "vim-mode-pulse-cycle-a"
288 if f.pulseCycle {
289 cycle = "vim-mode-pulse-cycle-b"
290 }
291 f.pulseCycle = !f.pulseCycle
292 f.borderStyle.AddCssClass(cycle)
293}
294
295func modeFrameClass(mode input.Mode) string {
296 switch mode {
297 case input.ModePane:
298 return "pane-mode-active"
299 case input.ModeResize:
300 return "resize-mode-active"
301 case input.ModeVim:
302 return "vim-mode-active"
303 case input.ModeTab:
304 return "tab-mode-active"
305 case input.ModeSession:
306 return "session-mode-active"
307 default:
308 return ""
309 }
310}
311
312func (f *modeFrame) setMode(
313 mode input.Mode, target layout.Widget, cfg entity.WorkspaceStylingConfig,
314 style string, actions map[string]entity.ActionBinding,
315) {
316 if f == nil {
317 return
318 }
319 f.cancelTimers()
320 // Normal-mode refreshes must preserve an already lingering legend.
321 keepLinger := mode == input.ModeNormal && f.mode == input.ModeNormal && f.lingering
322 linger := shouldStartLinger(mode == input.ModeNormal && f.visible, f.pressInFlight,
323 cfg.ModeLegendLinger, f.pointerInside, f.pointerX, f.pointerY, f.placement)
324 if !keepLinger {
325 f.dismissLinger()
326 }
327 f.generation++
328 f.mode, f.config = mode, cfg
329 f.pending = ""
330 f.pulseCycle = false
331 if linger {
332 f.lingering = true
333 f.visible = false
334 f.clearLegendFeedback()
335 f.panel.AddCssClass("mode-legend-lingering")
336 } else if !keepLinger {
337 f.hide()
338 }
339 f.setTarget(target, modeFrameClass(mode))
340 f.setLegendStyle(style, mode, linger || keepLinger)
341 if mode == input.ModeNormal || cfg.ModeLegend == "off" || target == nil {
342 if !linger && !keepLinger {
343 f.content.RemoveAll()
344 }
345 f.rows = nil
346 f.keycaps = nil
347 return
348 }
349 f.build(actions)
350 if cfg.ModeLegendAnimations {
351 f.panel.RemoveCssClass("mode-legend-no-motion")
352 } else {
353 f.panel.AddCssClass("mode-legend-no-motion")
354 }
355 if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
356 generation := f.generation
357 cb := glib.SourceFunc(func(_ uintptr) bool {
358 f.showTimer = 0
359 if generation == f.generation {
360 f.show()
361 }
362 return false
363 })
364 f.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0)
365 return
366 }
367 f.show()
368}
369
370func (f *modeFrame) setLegendStyle(style string, mode input.Mode, preserveModeClass bool) {
371 if f.style != style {
372 if f.style != "" {
373 f.panel.RemoveCssClass("omnibox-style-" + f.style)
374 }
375 f.style = style
376 if style != "" {
377 f.panel.AddCssClass("omnibox-style-" + style)
378 }
379 }
380 if f.modeClass != "" && !preserveModeClass {
381 f.panel.RemoveCssClass(f.modeClass)
382 }
383 if !preserveModeClass {
384 f.modeClass = "mode-legend-" + mode.String()
385 f.panel.AddCssClass(f.modeClass)
386 }
387}
388
389func (f *modeFrame) clearLegendFeedback() {
390 for _, rows := range f.rows {
391 for _, row := range rows {
392 row.RemoveCssClass("mode-legend-dim")
393 }
394 }
395 for _, caps := range f.keycaps {
396 for _, cap := range caps {
397 cap.RemoveCssClass("mode-legend-flash")
398 }
399 }
400}
401
402func (f *modeFrame) cancelTimers() {
403 if f == nil {
404 return
405 }
406 if f.showTimer != 0 {
407 glib.SourceRemove(f.showTimer)
408 f.showTimer = 0
409 }
410 if f.flashTimer != 0 {
411 glib.SourceRemove(f.flashTimer)
412 f.flashTimer = 0
413 }
414}
415
416func pointInRect(x, y float64, rx, ry, width, height int) bool {
417 return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height)
418}
419
420func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
421 if !enabled || !inside || placement == nil {
422 return false
423 }
424 rx, ry, w, h, ok := placement()
425 return ok && pointInRect(x, y, rx, ry, w, h)
426}
427
428func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
429 return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
430}
431
432func (f *modeFrame) dismissLinger() {
433 if f == nil || !f.lingering {
434 return
435 }
436 f.lingering = false
437 f.panel.RemoveCssClass("mode-legend-lingering")
438 f.hide()
439}
440
441func (f *modeFrame) hide() {
442 if f == nil || f.root == nil {
443 return
444 }
445 f.visible = false
446 f.placement = nil
447 f.resetLegendHeight()
448 f.root.SetVisible(false)
449}
450
451func (f *modeFrame) stopGeometryRefresh() {
452 if f == nil || f.refreshSource == 0 {
453 return
454 }
455 glib.SourceRemove(f.refreshSource)
456 f.refreshSource = 0
457}
458
459func (f *modeFrame) show() {
460 if f == nil || f.mode == input.ModeNormal || f.target == nil {
461 return
462 }
463 f.visible = true
464 f.startGeometryRefresh()
465}
466
467// Measure outside GTK allocation while the legend is active, including window
468// resizes and splitter drags after the initial layout has settled.
469func (f *modeFrame) startGeometryRefresh() {
470 f.stopGeometryRefresh()
471 f.refreshGeometry()
472 cb := glib.SourceFunc(func(_ uintptr) bool {
473 if f.target == nil || f.root == nil || f.lingering {
474 f.refreshSource = 0
475 return false
476 }
477 f.refreshGeometry()
478 return true
479 })
480 f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)
481}
482
483func (f *modeFrame) resetLegendHeight() {
484 if f.lastMaxHeight != 0 {
485 f.scroller.SetSizeRequest(-1, -1)
486 f.scroller.SetPropagateNaturalHeight(true)
487 f.lastMaxHeight = 0
488 }
489}
490
491// clearPlacement hides both the border and the legend: the target is gone.
492func (f *modeFrame) clearPlacement() {
493 if f.borderRect != nil {
494 f.borderRect = nil
495 f.border.SetVisible(false)
496 }
497 f.clearLegend()
498}
499
500// clearLegend hides only the legend; the border keeps tracking its target.
501func (f *modeFrame) clearLegend() {
502 if f.placement == nil {
503 return
504 }
505 f.placement = nil
506 f.root.SetVisible(false)
507 f.root.QueueAllocate()
508}
509
510func (f *modeFrame) measureLegendHeight(panelWidth int) int {
511 maxHeight := f.overlay.GetAllocatedHeight() - legendInset
512 if f.lastMaxHeight != 0 && maxHeight > f.lastMaxHeight {
513 f.resetLegendHeight()
514 }
515 // Measure the panel: GTK reports 0 for the root while it is still hidden.
516 _, naturalHeight := 0, 0
517 f.panel.Measure(gtk.OrientationVerticalValue, panelWidth, nil, &naturalHeight, nil, nil)
518 if naturalHeight > maxHeight && maxHeight > 0 {
519 if f.lastMaxHeight != maxHeight {
520 f.scroller.SetPropagateNaturalHeight(false)
521 f.scroller.SetSizeRequest(-1, max(maxHeight-legendHeaderSpace, 1))
522 f.lastMaxHeight = maxHeight
523 }
524 return maxHeight
525 }
526 return naturalHeight
527}
528
529// refreshGeometry measures the legend outside GTK's positioning callback.
530// A narrow target falls back to the window width, keeping the legend reachable.
531func (f *modeFrame) refreshGeometry() {
532 if f.lingering {
533 return
534 }
535 previous := f.placement
536 if f.target == nil || f.overlay == nil {
537 f.clearPlacement()
538 return
539 }
540 overlayWidget := &f.overlay.Widget
541 point := &graphene.Point{X: 0, Y: 0}
542 out := &graphene.Point{}
543 target := f.target.GtkWidget()
544 if target == nil || !target.ComputePoint(overlayWidget, point, out) {
545 f.clearPlacement()
546 return
547 }
548 width, height := target.GetAllocatedWidth(), target.GetAllocatedHeight()
549 if width <= 0 || height <= 0 {
550 f.clearPlacement()
551 return
552 }
553 x, bottom := int(out.X), int(out.Y)+height
554 f.placeBorder(x, int(out.Y), width, height)
555 if !f.visible {
556 f.clearLegend()
557 return
558 }
559 // Keep every keycap visible even in a narrow split. No webview resize.
560 panelWidth := min(width-legendInset, legendMaxWidth)
561 columns := uint(3)
562 if panelWidth < legendCompactWidth {
563 panelWidth = min(f.overlay.GetAllocatedWidth()-legendInset, legendCompactWidth)
564 x = (f.overlay.GetAllocatedWidth() - panelWidth) / 2
565 columns = 1
566 } else {
567 if panelWidth < legendTwoColumnWidth {
568 columns = 2
569 }
570 x += (width - panelWidth) / 2
571 }
572 if panelWidth <= 0 {
573 f.clearLegend()
574 return
575 }
576 f.sizeLegendColumns(columns, panelWidth)
577 naturalHeight := f.measureLegendHeight(panelWidth)
578 if naturalHeight <= 0 {
579 f.clearLegend()
580 return
581 }
582 y := bottom - naturalHeight - 4
583 if y < 0 {
584 y = 0
585 }
586 f.placement = func() (int, int, int, int, bool) { return x, y, panelWidth, naturalHeight, true }
587 f.root.SetVisible(true)
588 if previous != nil {
589 oldX, oldY, oldWidth, oldHeight, _ := previous()
590 if oldX == x && oldY == y && oldWidth == panelWidth && oldHeight == naturalHeight {
591 return
592 }
593 }
594 f.root.QueueAllocate()
595}
596
597func (f *modeFrame) placeBorder(x, y, width, height int) {
598 if f.borderRect != nil {
599 if ox, oy, ow, oh, _ := f.borderRect(); ox == x && oy == y && ow == width && oh == height {
600 return
601 }
602 }
603 f.borderRect = func() (int, int, int, int, bool) { return x, y, width, height, true }
604 f.border.SetVisible(true)
605 f.border.QueueAllocate()
606}
607
608func (f *modeFrame) sizeLegendColumns(columns uint, width int) {
609 if columns != f.lastColumns {
610 f.content.SetMaxChildrenPerLine(columns)
611 f.lastColumns = columns
612 }
613 if width != f.lastPanelWidth {
614 f.panel.SetSizeRequest(width, -1)
615 f.lastPanelWidth = width
616 }
617}
618
619func modeLegendGroup(mode input.Mode, name string) string {
620 switch {
621 case name == "confirm" || name == "cancel":
622 return "EXIT"
623 case strings.HasPrefix(name, "split-"):
624 return "SPLIT"
625 case strings.HasPrefix(name, "focus-"):
626 return "FOCUS"
627 case strings.HasPrefix(name, "resize-"):
628 return "RESIZE"
629 case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
630 return "SCROLL"
631 case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):
632 return "JUMP"
633 case mode == input.ModeTab && (name == "next-tab" || name == "previous-tab"):
634 return "SWITCH"
635 default:
636 return "MANAGE"
637 }
638}