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 case input.ModePrefix:
308 return "prefix-mode-active"
309 default:
310 return ""
311 }
312}
313
314func (f *modeFrame) setMode(
315 mode input.Mode, target layout.Widget, cfg entity.WorkspaceStylingConfig,
316 style string, actions map[string]entity.ActionBinding,
317) {
318 if f == nil {
319 return
320 }
321 f.cancelTimers()
322 // Normal-mode refreshes must preserve an already lingering legend.
323 keepLinger := mode == input.ModeNormal && f.mode == input.ModeNormal && f.lingering
324 linger := shouldStartLinger(mode == input.ModeNormal && f.visible, f.pressInFlight,
325 cfg.ModeLegendLinger, f.pointerInside, f.pointerX, f.pointerY, f.placement)
326 if !keepLinger {
327 f.dismissLinger()
328 }
329 f.generation++
330 f.mode, f.config = mode, cfg
331 f.pending = ""
332 f.pulseCycle = false
333 if linger {
334 f.lingering = true
335 f.visible = false
336 f.clearLegendFeedback()
337 f.panel.AddCssClass("mode-legend-lingering")
338 } else if !keepLinger {
339 f.hide()
340 }
341 f.setTarget(target, modeFrameClass(mode))
342 f.setLegendStyle(style, mode, linger || keepLinger)
343 if mode == input.ModeNormal || cfg.ModeLegend == "off" || target == nil {
344 if !linger && !keepLinger {
345 f.content.RemoveAll()
346 }
347 f.rows = nil
348 f.keycaps = nil
349 return
350 }
351 f.build(actions)
352 if cfg.ModeLegendAnimations {
353 f.panel.RemoveCssClass("mode-legend-no-motion")
354 } else {
355 f.panel.AddCssClass("mode-legend-no-motion")
356 }
357 if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
358 generation := f.generation
359 cb := glib.SourceFunc(func(_ uintptr) bool {
360 f.showTimer = 0
361 if generation == f.generation {
362 f.show()
363 }
364 return false
365 })
366 f.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0)
367 return
368 }
369 f.show()
370}
371
372func (f *modeFrame) setLegendStyle(style string, mode input.Mode, preserveModeClass bool) {
373 if f.style != style {
374 if f.style != "" {
375 f.panel.RemoveCssClass("omnibox-style-" + f.style)
376 }
377 f.style = style
378 if style != "" {
379 f.panel.AddCssClass("omnibox-style-" + style)
380 }
381 }
382 if f.modeClass != "" && !preserveModeClass {
383 f.panel.RemoveCssClass(f.modeClass)
384 }
385 if !preserveModeClass {
386 f.modeClass = "mode-legend-" + mode.String()
387 f.panel.AddCssClass(f.modeClass)
388 }
389}
390
391func (f *modeFrame) clearLegendFeedback() {
392 for _, rows := range f.rows {
393 for _, row := range rows {
394 row.RemoveCssClass("mode-legend-dim")
395 }
396 }
397 for _, caps := range f.keycaps {
398 for _, cap := range caps {
399 cap.RemoveCssClass("mode-legend-flash")
400 }
401 }
402}
403
404func (f *modeFrame) cancelTimers() {
405 if f == nil {
406 return
407 }
408 if f.showTimer != 0 {
409 glib.SourceRemove(f.showTimer)
410 f.showTimer = 0
411 }
412 if f.flashTimer != 0 {
413 glib.SourceRemove(f.flashTimer)
414 f.flashTimer = 0
415 }
416}
417
418func pointInRect(x, y float64, rx, ry, width, height int) bool {
419 return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height)
420}
421
422func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
423 if !enabled || !inside || placement == nil {
424 return false
425 }
426 rx, ry, w, h, ok := placement()
427 return ok && pointInRect(x, y, rx, ry, w, h)
428}
429
430func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
431 return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
432}
433
434func (f *modeFrame) dismissLinger() {
435 if f == nil || !f.lingering {
436 return
437 }
438 f.lingering = false
439 f.panel.RemoveCssClass("mode-legend-lingering")
440 f.hide()
441}
442
443func (f *modeFrame) hide() {
444 if f == nil || f.root == nil {
445 return
446 }
447 f.visible = false
448 f.placement = nil
449 f.resetLegendHeight()
450 f.root.SetVisible(false)
451}
452
453func (f *modeFrame) stopGeometryRefresh() {
454 if f == nil || f.refreshSource == 0 {
455 return
456 }
457 glib.SourceRemove(f.refreshSource)
458 f.refreshSource = 0
459}
460
461func (f *modeFrame) show() {
462 if f == nil || f.mode == input.ModeNormal || f.target == nil {
463 return
464 }
465 f.visible = true
466 f.startGeometryRefresh()
467}
468
469// Measure outside GTK allocation while the legend is active, including window
470// resizes and splitter drags after the initial layout has settled.
471func (f *modeFrame) startGeometryRefresh() {
472 f.stopGeometryRefresh()
473 f.refreshGeometry()
474 cb := glib.SourceFunc(func(_ uintptr) bool {
475 if f.target == nil || f.root == nil || f.lingering {
476 f.refreshSource = 0
477 return false
478 }
479 f.refreshGeometry()
480 return true
481 })
482 f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)
483}
484
485func (f *modeFrame) resetLegendHeight() {
486 if f.lastMaxHeight != 0 {
487 f.scroller.SetSizeRequest(-1, -1)
488 f.scroller.SetPropagateNaturalHeight(true)
489 f.lastMaxHeight = 0
490 }
491}
492
493// clearPlacement hides both the border and the legend: the target is gone.
494func (f *modeFrame) clearPlacement() {
495 if f.borderRect != nil {
496 f.borderRect = nil
497 f.border.SetVisible(false)
498 }
499 f.clearLegend()
500}
501
502// clearLegend hides only the legend; the border keeps tracking its target.
503func (f *modeFrame) clearLegend() {
504 if f.placement == nil {
505 return
506 }
507 f.placement = nil
508 f.root.SetVisible(false)
509 f.root.QueueAllocate()
510}
511
512func (f *modeFrame) measureLegendHeight(panelWidth int) int {
513 maxHeight := f.overlay.GetAllocatedHeight() - legendInset
514 if f.lastMaxHeight != 0 && maxHeight > f.lastMaxHeight {
515 f.resetLegendHeight()
516 }
517 // Measure the panel: GTK reports 0 for the root while it is still hidden.
518 _, naturalHeight := 0, 0
519 f.panel.Measure(gtk.OrientationVerticalValue, panelWidth, nil, &naturalHeight, nil, nil)
520 if naturalHeight > maxHeight && maxHeight > 0 {
521 if f.lastMaxHeight != maxHeight {
522 f.scroller.SetPropagateNaturalHeight(false)
523 f.scroller.SetSizeRequest(-1, max(maxHeight-legendHeaderSpace, 1))
524 f.lastMaxHeight = maxHeight
525 }
526 return maxHeight
527 }
528 return naturalHeight
529}
530
531// refreshGeometry measures the legend outside GTK's positioning callback.
532// A narrow target falls back to the window width, keeping the legend reachable.
533func (f *modeFrame) refreshGeometry() {
534 if f.lingering {
535 return
536 }
537 previous := f.placement
538 if f.target == nil || f.overlay == nil {
539 f.clearPlacement()
540 return
541 }
542 overlayWidget := &f.overlay.Widget
543 point := &graphene.Point{X: 0, Y: 0}
544 out := &graphene.Point{}
545 target := f.target.GtkWidget()
546 if target == nil || !target.ComputePoint(overlayWidget, point, out) {
547 f.clearPlacement()
548 return
549 }
550 width, height := target.GetAllocatedWidth(), target.GetAllocatedHeight()
551 if width <= 0 || height <= 0 {
552 f.clearPlacement()
553 return
554 }
555 x, bottom := int(out.X), int(out.Y)+height
556 f.placeBorder(x, int(out.Y), width, height)
557 if !f.visible {
558 f.clearLegend()
559 return
560 }
561 // Keep every keycap visible even in a narrow split. No webview resize.
562 panelWidth := min(width-legendInset, legendMaxWidth)
563 columns := uint(3)
564 if panelWidth < legendCompactWidth {
565 panelWidth = min(f.overlay.GetAllocatedWidth()-legendInset, legendCompactWidth)
566 x = (f.overlay.GetAllocatedWidth() - panelWidth) / 2
567 columns = 1
568 } else {
569 if panelWidth < legendTwoColumnWidth {
570 columns = 2
571 }
572 x += (width - panelWidth) / 2
573 }
574 if panelWidth <= 0 {
575 f.clearLegend()
576 return
577 }
578 f.sizeLegendColumns(columns, panelWidth)
579 naturalHeight := f.measureLegendHeight(panelWidth)
580 if naturalHeight <= 0 {
581 f.clearLegend()
582 return
583 }
584 y := bottom - naturalHeight - 4
585 if y < 0 {
586 y = 0
587 }
588 f.placement = func() (int, int, int, int, bool) { return x, y, panelWidth, naturalHeight, true }
589 f.root.SetVisible(true)
590 if previous != nil {
591 oldX, oldY, oldWidth, oldHeight, _ := previous()
592 if oldX == x && oldY == y && oldWidth == panelWidth && oldHeight == naturalHeight {
593 return
594 }
595 }
596 f.root.QueueAllocate()
597}
598
599func (f *modeFrame) placeBorder(x, y, width, height int) {
600 if f.borderRect != nil {
601 if ox, oy, ow, oh, _ := f.borderRect(); ox == x && oy == y && ow == width && oh == height {
602 return
603 }
604 }
605 f.borderRect = func() (int, int, int, int, bool) { return x, y, width, height, true }
606 f.border.SetVisible(true)
607 f.border.QueueAllocate()
608}
609
610func (f *modeFrame) sizeLegendColumns(columns uint, width int) {
611 if columns != f.lastColumns {
612 f.content.SetMaxChildrenPerLine(columns)
613 f.lastColumns = columns
614 }
615 if width != f.lastPanelWidth {
616 f.panel.SetSizeRequest(width, -1)
617 f.lastPanelWidth = width
618 }
619}
620
621func modeLegendGroup(mode input.Mode, name string) string {
622 switch {
623 case name == "confirm" || name == "cancel":
624 return "EXIT"
625 case strings.HasPrefix(name, "split-"):
626 return "SPLIT"
627 case strings.HasPrefix(name, "enter-"):
628 return "MODES"
629 case strings.HasPrefix(name, "focus-"):
630 return "FOCUS"
631 case strings.HasPrefix(name, "resize-"):
632 return "RESIZE"
633 case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
634 return "SCROLL"
635 case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):
636 return "JUMP"
637 case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"):
638 return "SWITCH"
639 default:
640 return "MANAGE"
641 }
642}