1package ui
2
3import (
4 "github.com/bnema/dumber/internal/domain/entity"
5 "github.com/bnema/dumber/internal/ui/input"
6 "github.com/bnema/dumber/internal/ui/layout"
7 "github.com/bnema/puregotk/v4/glib"
8 "github.com/bnema/puregotk/v4/gobject"
9 "github.com/bnema/puregotk/v4/graphene"
10 "github.com/bnema/puregotk/v4/gtk"
11)
12
13const borderRefreshMs = 100
14
15// modeFrame owns the per-window mode border drawn around its target pane or
16// workspace, and the Vim pulse on it. The mode indicator and legend belong to
17// modeToaster. GTK access is restricted to the main thread, as with the rest of
18// the UI.
19type modeFrame struct {
20 mode input.Mode
21 tabID entity.TabID
22 target layout.Widget
23 frameClass string
24 border *gtk.Box
25 borderStyle cssClassTarget // border's CSS classes; a seam for tests
26 borderRect func() (x, y, width, height int, ok bool)
27 overlay *gtk.Overlay
28
29 refreshSource uint
30 positionHandlerID uint
31 pulseCycle bool
32}
33
34func newModeFrame(overlay *gtk.Overlay) *modeFrame {
35 if overlay == nil {
36 return nil
37 }
38 f := &modeFrame{mode: input.ModeNormal, overlay: overlay}
39 // The border is its own overlay: inset shadows on pane containers are
40 // painted below web content and would be invisible.
41 f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
42 f.border.SetCanFocus(false)
43 f.border.SetCanTarget(false)
44 f.border.SetVisible(false)
45 f.border.AddCssClass("mode-frame-border")
46 f.borderStyle = f.border
47 overlay.AddOverlay(&f.border.Widget)
48 overlay.SetClipOverlay(&f.border.Widget, false)
49 overlay.SetMeasureOverlay(&f.border.Widget, false)
50 // A single positioned overlay tracks the active pane without reparenting it.
51 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
52 if f.border == nil || widgetPtr != f.border.GoPointer() || f.borderRect == nil {
53 return false
54 }
55 x, y, w, h, ok := f.borderRect()
56 if !ok {
57 return false
58 }
59 return writeOverlayAllocation(allocation, x, y, w, h)
60 }
61 f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
62 return f
63}
64
65func (f *modeFrame) destroy() {
66 if f == nil {
67 return
68 }
69 f.stopGeometryRefresh()
70 f.setTarget(nil, "")
71 if f.overlay != nil && f.positionHandlerID != 0 {
72 gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
73 f.positionHandlerID = 0
74 }
75 f.borderRect = nil
76 if f.overlay != nil && f.border != nil {
77 f.overlay.RemoveOverlay(&f.border.Widget)
78 // NewBox's reference is not released by the removal.
79 f.border.Unref()
80 }
81 f.overlay = nil
82 f.border = nil
83 f.borderStyle = nil
84}
85
86type cssClassTarget interface {
87 AddCssClass(string)
88 RemoveCssClass(string)
89}
90
91// setMode retargets the border for mode; it clears the border for Normal mode,
92// which has no target.
93func (f *modeFrame) setMode(mode input.Mode, target layout.Widget) {
94 if f == nil {
95 return
96 }
97 f.mode = mode
98 f.pulseCycle = false
99 f.setTarget(target, modeFrameClass(mode))
100}
101
102func (f *modeFrame) setTarget(target layout.Widget, class string) {
103 if f == nil {
104 return
105 }
106 if target == f.target && class == f.frameClass {
107 return
108 }
109 if f.borderStyle != nil && f.frameClass != "" {
110 f.borderStyle.RemoveCssClass(f.frameClass)
111 for _, pulseClass := range vimPulseClasses {
112 f.borderStyle.RemoveCssClass(pulseClass)
113 }
114 }
115 f.target, f.frameClass = target, class
116 if f.borderStyle != nil && target != nil && class != "" {
117 f.borderStyle.AddCssClass(class)
118 }
119 if f.border == nil {
120 return
121 }
122 if target != nil && class != "" {
123 f.startGeometryRefresh()
124 return
125 }
126 f.stopGeometryRefresh()
127 f.borderRect = nil
128 f.border.SetVisible(false)
129}
130
131var vimPulseClasses = []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"}
132
133func (f *modeFrame) pulse(fast bool) {
134 if f == nil || f.mode != input.ModeVim || f.target == nil || f.borderStyle == nil {
135 return
136 }
137 for _, class := range vimPulseClasses {
138 f.borderStyle.RemoveCssClass(class)
139 }
140 class := "vim-mode-pulse"
141 if fast {
142 class = "vim-mode-pulse-fast"
143 }
144 f.borderStyle.AddCssClass(class)
145 cycle := "vim-mode-pulse-cycle-a"
146 if f.pulseCycle {
147 cycle = "vim-mode-pulse-cycle-b"
148 }
149 f.pulseCycle = !f.pulseCycle
150 f.borderStyle.AddCssClass(cycle)
151}
152
153func modeFrameClass(mode input.Mode) string {
154 switch mode {
155 case input.ModePane:
156 return "pane-mode-active"
157 case input.ModeResize:
158 return "resize-mode-active"
159 case input.ModeVim:
160 return "vim-mode-active"
161 case input.ModeTab:
162 return "tab-mode-active"
163 case input.ModeSession:
164 return "session-mode-active"
165 case input.ModePrefix:
166 return "prefix-mode-active"
167 default:
168 return ""
169 }
170}
171
172func (f *modeFrame) stopGeometryRefresh() {
173 if f == nil || f.refreshSource == 0 {
174 return
175 }
176 glib.SourceRemove(f.refreshSource)
177 f.refreshSource = 0
178}
179
180// Measure outside GTK allocation while the border is active, including window
181// resizes and splitter drags after the initial layout has settled.
182func (f *modeFrame) startGeometryRefresh() {
183 f.stopGeometryRefresh()
184 f.refreshGeometry()
185 cb := glib.SourceFunc(func(_ uintptr) bool {
186 if f.target == nil || f.border == nil {
187 f.refreshSource = 0
188 return false
189 }
190 f.refreshGeometry()
191 return true
192 })
193 f.refreshSource = glib.TimeoutAdd(borderRefreshMs, &cb, 0)
194}
195
196// clearPlacement hides the border: the target is gone.
197func (f *modeFrame) clearPlacement() {
198 if f.borderRect != nil {
199 f.borderRect = nil
200 f.border.SetVisible(false)
201 }
202}
203
204// refreshGeometry measures the target outside GTK's positioning callback.
205func (f *modeFrame) refreshGeometry() {
206 if f.target == nil || f.overlay == nil {
207 f.clearPlacement()
208 return
209 }
210 point := &graphene.Point{X: 0, Y: 0}
211 out := &graphene.Point{}
212 target := f.target.GtkWidget()
213 if target == nil || !target.ComputePoint(&f.overlay.Widget, point, out) {
214 f.clearPlacement()
215 return
216 }
217 width, height := target.GetAllocatedWidth(), target.GetAllocatedHeight()
218 if width <= 0 || height <= 0 {
219 f.clearPlacement()
220 return
221 }
222 f.placeBorder(int(out.X), int(out.Y), width, height)
223}
224
225func (f *modeFrame) placeBorder(x, y, width, height int) {
226 if f.borderRect != nil {
227 if ox, oy, ow, oh, _ := f.borderRect(); ox == x && oy == y && ow == width && oh == height {
228 return
229 }
230 }
231 f.borderRect = func() (int, int, int, int, bool) { return x, y, width, height, true }
232 f.border.SetVisible(true)
233 f.border.QueueAllocate()
234}