1package ui
2
3import (
4 "github.com/bnema/dumber/internal/ui/input"
5 "github.com/bnema/puregotk/v4/gdk"
6 "github.com/bnema/puregotk/v4/glib"
7 "github.com/bnema/puregotk/v4/gobject"
8 "github.com/bnema/puregotk/v4/graphene"
9 "github.com/bnema/puregotk/v4/gtk"
10)
11
12const (
13 legendRefreshMs = 100
14 // legendGap separates the legend band from the pill under it.
15 legendGap = 6
16 // legendScrollStep is how far one wheel notch over the pill scrolls a
17 // legend taller than its height budget.
18 legendScrollStep = 40
19)
20
21// modeToasterEvents carries window events to the toaster. The view only
22// forwards them; modeToaster decides what they mean.
23type modeToasterEvents struct {
24 pillClicked func()
25 pointerMoved func(x, y float64)
26 pointerLeft func()
27 keyPressed func()
28 // pressedOutside reports a pointer press anywhere but on the pill.
29 pressedOutside func()
30}
31
32// gtkModeToasterView paints the mode toaster into the window's content
33// overlay as two overlay children:
34//
35// - the pill, the only targetable widget of the component. It is a sibling
36// of the legend rather than its parent because GTK skips the descendants of
37// a widget that cannot be targeted.
38// - the legend body, which never receives the pointer. Its rectangle is
39// measured outside GTK's allocation pass and handed to the overlay's
40// get-child-position callback, like the mode border's.
41//
42// Main thread only.
43type gtkModeToasterView struct {
44 overlay *gtk.Overlay
45 window *gtk.ApplicationWindow
46 events modeToasterEvents
47
48 pill *gtk.Box
49 pillLabel *gtk.Label
50 chevron *gtk.Label
51 root *gtk.Box // overlay child of the body; its panel is what is measured
52 panel *gtk.Box
53 scroller *gtk.ScrolledWindow
54 columnBox *gtk.Box
55 hint *gtk.Label
56
57 pillClick *gtk.GestureClick
58 pillScroll *gtk.EventControllerScroll
59 motion *gtk.EventControllerMotion
60 keys *gtk.EventControllerKey
61 windowClick *gtk.GestureClick
62
63 // Callbacks are retained here: GTK holds only a pointer to them.
64 pillClickCb func(gtk.GestureClick, int, float64, float64)
65 pillScrollCb func(gtk.EventControllerScroll, float64, float64) bool
66 motionCb func(gtk.EventControllerMotion, float64, float64)
67 enterCb func(gtk.EventControllerMotion, float64, float64)
68 leaveCb func(gtk.EventControllerMotion)
69 keyCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
70 windowClickC func(gtk.GestureClick, int, float64, float64)
71
72 positionHandlerID uint
73 refreshSource uint
74
75 // Painted state, kept to skip redundant GTK calls.
76 headerText string
77 modeClass string // CSS class of the pill color
78 legendClass string // CSS class of the panel color
79 styleClass string
80 chevronState chevronState
81 pillShown bool
82 interactive bool
83 animations bool
84
85 // legendWidgets are the widgets built for the current legend. puregotk
86 // constructors hand back a reference we own, which GTK does not drop when
87 // the widget is removed from its parent, so they are unreferenced once the
88 // legend is replaced or the view destroyed.
89 legendWidgets []*gtk.Widget
90
91 groups []legendGroup
92 groupBoxes []*gtk.Box
93 columns [][]*gtk.Box // group boxes per column box, as placed
94 columnBoxes []*gtk.Box
95 rows map[string][]*gtk.Widget
96 keycaps map[string][]*gtk.Widget
97
98 bodyWanted bool
99 lingering bool
100 // placement is the legend rectangle in overlay coordinates; it is only
101 // meaningful while placed is set, which otherwise hides the legend.
102 placement overlayRect
103 placed bool
104 lastColumns int
105 lastWidth, lastMaxContent int
106}
107
108// overlayRect is a rectangle in overlay coordinates.
109type overlayRect struct{ x, y, width, height int }
110
111func newGTKModeToasterView(overlay *gtk.Overlay, window *gtk.ApplicationWindow, events modeToasterEvents) *gtkModeToasterView {
112 if overlay == nil || window == nil {
113 return nil
114 }
115 v := >kModeToasterView{overlay: overlay, window: window, events: events, animations: true}
116 v.buildPill()
117 v.buildBody()
118 overlay.AddOverlay(&v.pill.Widget)
119 overlay.SetClipOverlay(&v.pill.Widget, false)
120 overlay.SetMeasureOverlay(&v.pill.Widget, false)
121 overlay.AddOverlay(&v.root.Widget)
122 overlay.SetClipOverlay(&v.root.Widget, false)
123 overlay.SetMeasureOverlay(&v.root.Widget, false)
124 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
125 if v.root == nil || widgetPtr != v.root.GoPointer() || !v.placed {
126 return false
127 }
128 r := v.placement
129 return writeOverlayAllocation(allocation, r.x, r.y, r.width, r.height)
130 }
131 v.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
132 v.installWindowControllers()
133 return v
134}
135
136// buildPill creates the header. It starts out inert: pointer input reaches the
137// pill only while it can toggle a legend (setPillInteractive).
138func (v *gtkModeToasterView) buildPill() {
139 v.pill = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
140 v.pill.SetHalign(gtk.AlignStartValue)
141 v.pill.SetValign(gtk.AlignEndValue)
142 v.pill.SetHexpand(false)
143 v.pill.SetVexpand(false)
144 // Widget margins keep the allocation equal to the visible pill, which the
145 // click filter and the legend anchor rely on.
146 v.pill.SetMarginStart(modeToasterMargin)
147 v.pill.SetMarginBottom(modeToasterMargin)
148 v.pill.SetVisible(false)
149 // A click must never move keyboard focus off the pane's WebView: nothing in
150 // the pill can take focus, and a press is claimed by the pill's own gesture.
151 v.pill.SetCanFocus(false)
152 v.pill.SetFocusable(false)
153 v.pill.SetFocusOnClick(false)
154 v.pill.SetCanTarget(false)
155 v.pill.AddCssClass("mode-toaster-pill")
156
157 empty := ""
158 v.pillLabel = gtk.NewLabel(&empty)
159 v.pillLabel.SetCanTarget(false)
160 v.pillLabel.SetCanFocus(false)
161 v.pillLabel.SetFocusable(false)
162 v.pillLabel.AddCssClass("mode-toaster-label")
163 v.pill.Append(&v.pillLabel.Widget)
164
165 v.chevron = gtk.NewLabel(&empty)
166 v.chevron.SetCanTarget(false)
167 v.chevron.SetCanFocus(false)
168 v.chevron.SetFocusable(false)
169 v.chevron.SetVisible(false)
170 v.chevron.AddCssClass("mode-toaster-chevron")
171 v.pill.Append(&v.chevron.Widget)
172
173 v.pillClick = gtk.NewGestureClick()
174 v.pillClick.SetButton(1) // GDK_BUTTON_PRIMARY
175 v.pillClickCb = func(_ gtk.GestureClick, nPress int, _, _ float64) {
176 // Claim the sequence so no ancestor controller sees this press.
177 v.pillClick.SetState(gtk.EventSequenceClaimedValue)
178 if nPress == 1 && v.events.pillClicked != nil {
179 v.events.pillClicked()
180 }
181 }
182 v.pillClick.ConnectPressed(&v.pillClickCb)
183 v.pill.AddController(&v.pillClick.EventController)
184
185 // The legend itself is out of reach of the pointer, so the wheel over the
186 // pill scrolls a legend taller than its height budget.
187 v.pillScroll = gtk.NewEventControllerScroll(gtk.EventControllerScrollVerticalValue)
188 v.pillScrollCb = func(_ gtk.EventControllerScroll, _, dy float64) bool {
189 return v.scrollLegend(dy * legendScrollStep)
190 }
191 v.pillScroll.ConnectScroll(&v.pillScrollCb)
192 v.pill.AddController(&v.pillScroll.EventController)
193}
194
195func (v *gtkModeToasterView) buildBody() {
196 v.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
197 v.root.SetHalign(gtk.AlignStartValue)
198 v.root.SetValign(gtk.AlignStartValue)
199 v.root.SetCanFocus(false)
200 v.root.SetCanTarget(false)
201 v.root.SetVisible(false)
202 v.root.AddCssClass("mode-legend-anchor")
203
204 v.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
205 v.panel.SetCanFocus(false)
206 v.panel.SetCanTarget(false)
207 v.panel.AddCssClass("omnibox-container")
208 v.panel.AddCssClass("mode-legend-panel")
209 v.root.Append(&v.panel.Widget)
210
211 v.columnBox = gtk.NewBox(gtk.OrientationHorizontalValue, legendColumnGap)
212 v.columnBox.SetHomogeneous(true)
213 v.columnBox.SetCanFocus(false)
214 v.columnBox.SetCanTarget(false)
215 v.columnBox.AddCssClass("mode-legend-columns")
216
217 v.scroller = gtk.NewScrolledWindow()
218 v.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
219 v.scroller.SetPropagateNaturalHeight(true)
220 v.scroller.SetHasFrame(false)
221 v.scroller.SetCanFocus(false)
222 v.scroller.SetCanTarget(false)
223 v.scroller.SetChild(&v.columnBox.Widget)
224 v.panel.Append(&v.scroller.Widget)
225
226 hint := "esc to exit"
227 v.hint = gtk.NewLabel(&hint)
228 v.hint.SetCanFocus(false)
229 v.hint.SetCanTarget(false)
230 v.hint.SetHalign(gtk.AlignEndValue)
231 v.hint.AddCssClass("mode-legend-hint")
232 v.panel.Append(&v.hint.Widget)
233}
234
235// installWindowControllers watches the whole window, because a lingering
236// legend ends on any key press or click, wherever it lands.
237func (v *gtkModeToasterView) installWindowControllers() {
238 v.motion = gtk.NewEventControllerMotion()
239 v.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
240 if v.events.pointerMoved != nil {
241 v.events.pointerMoved(x, y)
242 }
243 }
244 v.enterCb = v.motionCb
245 v.leaveCb = func(_ gtk.EventControllerMotion) {
246 if v.events.pointerLeft != nil {
247 v.events.pointerLeft()
248 }
249 }
250 v.motion.ConnectEnter(&v.enterCb)
251 v.motion.ConnectMotion(&v.motionCb)
252 v.motion.ConnectLeave(&v.leaveCb)
253 v.overlay.AddController(&v.motion.EventController)
254
255 v.keys = gtk.NewEventControllerKey()
256 v.keys.SetPropagationPhase(gtk.PhaseCaptureValue)
257 v.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
258 if v.events.keyPressed != nil {
259 v.events.keyPressed()
260 }
261 return false
262 }
263 v.keys.ConnectKeyPressed(&v.keyCb)
264 v.window.AddController(&v.keys.EventController)
265
266 v.windowClick = gtk.NewGestureClick()
267 v.windowClick.SetButton(0)
268 v.windowClick.SetPropagationPhase(gtk.PhaseCaptureValue)
269 v.windowClickC = func(_ gtk.GestureClick, _ int, x, y float64) {
270 // A press on the pill is the user driving the toaster, not leaving it:
271 // it must not end a lingering legend or count as a click elsewhere.
272 if v.pillContains(x, y) {
273 return
274 }
275 if v.events.pressedOutside != nil {
276 v.events.pressedOutside()
277 }
278 }
279 v.windowClick.ConnectPressed(&v.windowClickC)
280 v.window.AddController(&v.windowClick.EventController)
281}
282
283// pillContains reports whether window point (x, y) lies on the interactive pill.
284func (v *gtkModeToasterView) pillContains(x, y float64) bool {
285 if !v.pillShown || !v.interactive {
286 return false
287 }
288 px, py, w, h, ok := v.pillBounds()
289 if !ok {
290 return false
291 }
292 // Window coordinates: the overlay sits below the window's own origin.
293 origin, out := &graphene.Point{}, &graphene.Point{}
294 if !v.overlay.ComputePoint(&v.window.Widget, origin, out) {
295 return false
296 }
297 return pointInRect(x, y, px+int(out.X), py+int(out.Y), w, h)
298}
299
300// pillBounds returns the pill's rectangle in overlay coordinates. The pill is
301// pinned to the bottom-left by alignment and a fixed margin, so the rectangle
302// follows from the overlay size. gtk_widget_compute_point is not used: for a
303// pill with CSS padding it reports the origin shifted by the padding, which
304// disagrees with where GTK draws and picks it.
305func (v *gtkModeToasterView) pillBounds() (x, y, width, height int, ok bool) {
306 if v.pill == nil || v.overlay == nil {
307 return 0, 0, 0, 0, false
308 }
309 width, height = v.pill.GetAllocatedWidth(), v.pill.GetAllocatedHeight()
310 if width <= 0 || height <= 0 {
311 return 0, 0, 0, 0, false
312 }
313 overlayHeight := v.overlay.GetAllocatedHeight()
314 return modeToasterMargin, overlayHeight - modeToasterMargin - height, width, height, true
315}
316
317func (v *gtkModeToasterView) setHeader(text string) {
318 if v.headerText == text {
319 return
320 }
321 v.headerText = text
322 v.pillLabel.SetText(text)
323}
324
325func (v *gtkModeToasterView) setMode(mode input.Mode) {
326 pillClass, panelClass := "", ""
327 if mode != input.ModeNormal {
328 pillClass = "mode-toaster-" + mode.String()
329 panelClass = "mode-legend-" + mode.String()
330 }
331 if pillClass != v.modeClass {
332 if v.modeClass != "" {
333 v.pill.RemoveCssClass(v.modeClass)
334 }
335 if pillClass != "" {
336 v.pill.AddCssClass(pillClass)
337 }
338 v.modeClass = pillClass
339 }
340 if panelClass != v.legendClass {
341 if v.legendClass != "" {
342 v.panel.RemoveCssClass(v.legendClass)
343 }
344 if panelClass != "" {
345 v.panel.AddCssClass(panelClass)
346 }
347 v.legendClass = panelClass
348 }
349}
350
351func (v *gtkModeToasterView) setChevron(state chevronState) {
352 if v.chevronState == state {
353 return
354 }
355 v.chevronState = state
356 switch state {
357 case chevronExpanded:
358 v.chevron.SetText("▾")
359 v.chevron.SetVisible(true)
360 v.setTooltip("Collapse the key legend")
361 case chevronCollapsed:
362 v.chevron.SetText("▴")
363 v.chevron.SetVisible(true)
364 v.setTooltip("Expand the key legend")
365 default:
366 v.chevron.SetVisible(false)
367 v.setTooltip("")
368 }
369}
370
371func (v *gtkModeToasterView) setTooltip(text string) {
372 if text == "" {
373 v.pill.SetTooltipText(nil)
374 return
375 }
376 v.pill.SetTooltipText(&text)
377}
378
379func (v *gtkModeToasterView) setPillVisible(visible bool) {
380 if v.pillShown == visible {
381 return
382 }
383 v.pillShown = visible
384 v.pill.SetVisible(visible)
385}
386
387func (v *gtkModeToasterView) setPillInteractive(interactive bool) {
388 if v.interactive == interactive {
389 return
390 }
391 v.interactive = interactive
392 v.pill.SetCanTarget(interactive)
393 if interactive {
394 v.pill.AddCssClass("mode-toaster-interactive")
395 cursor := "pointer"
396 v.pill.SetCursorFromName(&cursor)
397 return
398 }
399 v.pill.RemoveCssClass("mode-toaster-interactive")
400 v.pill.SetCursorFromName(nil)
401}
402
403func (v *gtkModeToasterView) setStyle(style string) {
404 if v.styleClass == "" && style == "" {
405 return
406 }
407 class := ""
408 if style != "" {
409 class = "omnibox-style-" + style
410 }
411 if class == v.styleClass {
412 return
413 }
414 if v.styleClass != "" {
415 v.panel.RemoveCssClass(v.styleClass)
416 }
417 if class != "" {
418 v.panel.AddCssClass(class)
419 }
420 v.styleClass = class
421}
422
423func (v *gtkModeToasterView) setAnimations(enabled bool) {
424 if v.animations == enabled {
425 return
426 }
427 v.animations = enabled
428 if enabled {
429 v.panel.RemoveCssClass("mode-legend-no-motion")
430 } else {
431 v.panel.AddCssClass("mode-legend-no-motion")
432 }
433}
434
435func (v *gtkModeToasterView) setBodyLingering(lingering bool) {
436 if v.lingering == lingering {
437 return
438 }
439 v.lingering = lingering
440 if lingering {
441 v.panel.AddCssClass("mode-legend-lingering")
442 v.stopRefresh()
443 return
444 }
445 v.panel.RemoveCssClass("mode-legend-lingering")
446 if v.bodyWanted {
447 // The frozen placement is stale; track the window again.
448 v.startRefresh()
449 }
450}
451
452// setLegend replaces the legend content; nil empties it.
453func (v *gtkModeToasterView) setLegend(groups []legendGroup) {
454 v.clearColumns()
455 v.releaseLegendWidgets()
456 v.groupBoxes = nil
457 v.rows = make(map[string][]*gtk.Widget)
458 v.keycaps = make(map[string][]*gtk.Widget)
459 v.groups = groups
460 for _, group := range groups {
461 v.groupBoxes = append(v.groupBoxes, v.buildGroup(group))
462 }
463 // Force the next geometry pass to place the groups.
464 v.lastColumns, v.lastWidth = 0, 0
465 if v.bodyWanted && !v.lingering {
466 v.refreshGeometry()
467 }
468}
469
470func (v *gtkModeToasterView) buildGroup(group legendGroup) *gtk.Box {
471 box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
472 v.legendWidgets = append(v.legendWidgets, &box.Widget)
473 box.SetCanFocus(false)
474 box.SetCanTarget(false)
475 box.AddCssClass("mode-legend-group")
476 name := group.Name
477 title := gtk.NewLabel(&name)
478 v.legendWidgets = append(v.legendWidgets, &title.Widget)
479 title.SetCanFocus(false)
480 title.SetCanTarget(false)
481 title.SetHalign(gtk.AlignStartValue)
482 title.AddCssClass("mode-legend-group-title")
483 box.Append(&title.Widget)
484 for _, row := range group.Rows {
485 box.Append(&v.buildRow(row).Widget)
486 }
487 return box
488}
489
490func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {
491 box := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
492 v.legendWidgets = append(v.legendWidgets, &box.Widget)
493 box.SetCanFocus(false)
494 box.SetCanTarget(false)
495 box.AddCssClass("mode-legend-row")
496 v.rows[row.Action] = append(v.rows[row.Action], &box.Widget)
497 for _, key := range row.Keys {
498 display := displayLegendKey(key)
499 keycap := gtk.NewLabel(&display)
500 v.legendWidgets = append(v.legendWidgets, &keycap.Widget)
501 keycap.SetCanFocus(false)
502 keycap.SetCanTarget(false)
503 keycap.AddCssClass("mode-legend-keycap")
504 box.Append(&keycap.Widget)
505 v.keycaps[row.Action] = append(v.keycaps[row.Action], &keycap.Widget)
506 }
507 desc := row.Desc
508 text := gtk.NewLabel(&desc)
509 v.legendWidgets = append(v.legendWidgets, &text.Widget)
510 text.SetCanFocus(false)
511 text.SetCanTarget(false)
512 text.SetHalign(gtk.AlignStartValue)
513 text.SetEllipsize(3) // PangoEllipsizeEnd
514 text.AddCssClass("mode-legend-description")
515 box.Append(&text.Widget)
516 return box
517}
518
519// clearColumns detaches the group boxes and drops the column boxes, keeping
520// the group boxes for the next layout.
521func (v *gtkModeToasterView) clearColumns() {
522 for i, col := range v.columnBoxes {
523 for _, group := range v.columns[i] {
524 col.Remove(&group.Widget)
525 }
526 v.columnBox.Remove(&col.Widget)
527 col.Unref() // drop the reference NewBox gave us
528 }
529 v.columnBoxes, v.columns = nil, nil
530}
531
532// releaseLegendWidgets drops our reference to every widget of the current
533// legend. The group boxes must already be detached from the columns.
534func (v *gtkModeToasterView) releaseLegendWidgets() {
535 for _, widget := range v.legendWidgets {
536 widget.Unref()
537 }
538 v.legendWidgets = nil
539 v.groupBoxes, v.groups = nil, nil
540 v.rows, v.keycaps = make(map[string][]*gtk.Widget), make(map[string][]*gtk.Widget)
541}
542
543// layoutColumns distributes the groups over count columns.
544func (v *gtkModeToasterView) layoutColumns(count int) {
545 v.clearColumns()
546 for _, indices := range distributeLegendGroups(legendGroupHeights(v.groups), count) {
547 col := gtk.NewBox(gtk.OrientationVerticalValue, 0)
548 col.SetCanFocus(false)
549 col.SetCanTarget(false)
550 col.SetHexpand(true)
551 col.SetValign(gtk.AlignStartValue)
552 col.AddCssClass("mode-legend-column")
553 placed := make([]*gtk.Box, 0, len(indices))
554 for _, i := range indices {
555 col.Append(&v.groupBoxes[i].Widget)
556 placed = append(placed, v.groupBoxes[i])
557 }
558 v.columnBox.Append(&col.Widget)
559 v.columnBoxes = append(v.columnBoxes, col)
560 v.columns = append(v.columns, placed)
561 }
562 v.lastColumns = count
563}
564
565func (v *gtkModeToasterView) setActionDimmed(action string, dimmed bool) {
566 for _, row := range v.rows[action] {
567 if dimmed {
568 row.AddCssClass("mode-legend-dim")
569 } else {
570 row.RemoveCssClass("mode-legend-dim")
571 }
572 }
573}
574
575func (v *gtkModeToasterView) flashAction(action string) bool {
576 caps := v.keycaps[action]
577 if len(caps) == 0 {
578 return false
579 }
580 v.clearFlash()
581 for _, keycap := range caps {
582 keycap.AddCssClass("mode-legend-flash")
583 }
584 return true
585}
586
587func (v *gtkModeToasterView) clearFlash() {
588 for _, caps := range v.keycaps {
589 for _, keycap := range caps {
590 keycap.RemoveCssClass("mode-legend-flash")
591 }
592 }
593}
594
595// setBodyVisible shows or hides the legend body. Showing waits for the first
596// successful measurement.
597func (v *gtkModeToasterView) setBodyVisible(visible bool) {
598 if visible == v.bodyWanted {
599 return
600 }
601 v.bodyWanted = visible
602 if !visible {
603 v.hideBody()
604 return
605 }
606 if !v.lingering {
607 v.startRefresh()
608 }
609}
610
611func (v *gtkModeToasterView) hideBody() {
612 v.stopRefresh()
613 v.placed = false
614 v.root.SetVisible(false)
615}
616
617func (v *gtkModeToasterView) lingerRect() (x, y, width, height int, ok bool) {
618 if !v.placed {
619 return 0, 0, 0, 0, false
620 }
621 r := v.placement
622 return r.x, r.y, r.width, r.height, true
623}
624
625func (v *gtkModeToasterView) scrollLegend(delta float64) bool {
626 adj := v.scroller.GetVadjustment()
627 if adj == nil || adj.GetUpper() <= adj.GetPageSize() {
628 return false
629 }
630 adj.SetValue(adj.GetValue() + delta)
631 return true
632}
633
634func (v *gtkModeToasterView) stopRefresh() {
635 if v.refreshSource != 0 {
636 glib.SourceRemove(v.refreshSource)
637 v.refreshSource = 0
638 }
639}
640
641// startRefresh measures outside GTK's allocation pass while the legend is
642// shown, which also follows window resizes after the initial layout settles.
643func (v *gtkModeToasterView) startRefresh() {
644 v.stopRefresh()
645 v.refreshGeometry()
646 cb := glib.SourceFunc(func(_ uintptr) bool {
647 if v.root == nil || !v.bodyWanted || v.lingering {
648 v.refreshSource = 0
649 return false
650 }
651 v.refreshGeometry()
652 return true
653 })
654 v.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)
655}
656
657// refreshGeometry places the legend above the pill, from the pill's left edge,
658// as wide as the window allows up to legendMaxWidth.
659func (v *gtkModeToasterView) refreshGeometry() {
660 if v.root == nil || v.overlay == nil || !v.bodyWanted || v.lingering {
661 return
662 }
663 width, height := v.overlay.GetAllocatedWidth(), v.overlay.GetAllocatedHeight()
664 if width <= 0 || height <= 0 || len(v.groups) == 0 {
665 v.clearPlacement()
666 return
667 }
668 left, bottom := modeToasterMargin, height-modeToasterMargin
669 if v.pillShown {
670 px, py, _, _, ok := v.pillBounds()
671 if !ok {
672 // The pill has not been allocated yet; the next tick retries.
673 v.clearPlacement()
674 return
675 }
676 left, bottom = px, py-legendGap
677 }
678 bandWidth, columns := legendBandWidth(width-left-modeToasterMargin, len(v.groups))
679 if bandWidth <= 0 {
680 v.clearPlacement()
681 return
682 }
683 if columns != v.lastColumns {
684 v.layoutColumns(columns)
685 }
686 if bandWidth != v.lastWidth {
687 v.panel.SetSizeRequest(bandWidth, -1)
688 v.lastWidth = bandWidth
689 }
690 if maxContent := legendBodyMaxHeight(height); maxContent != v.lastMaxContent {
691 v.scroller.SetMaxContentHeight(maxContent)
692 v.lastMaxContent = maxContent
693 }
694 // Measure the panel: GTK reports 0 for the hidden root.
695 naturalHeight := 0
696 v.panel.Measure(gtk.OrientationVerticalValue, bandWidth, nil, &naturalHeight, nil, nil)
697 if naturalHeight <= 0 {
698 v.clearPlacement()
699 return
700 }
701 y := max(bottom-naturalHeight, 0)
702 next := overlayRect{x: left, y: y, width: bandWidth, height: naturalHeight}
703 if v.placed && v.placement == next {
704 return
705 }
706 v.placement, v.placed = next, true
707 v.root.SetVisible(true)
708 v.root.QueueAllocate()
709}
710
711func (v *gtkModeToasterView) clearPlacement() {
712 if !v.placed {
713 return
714 }
715 v.placed = false
716 v.root.SetVisible(false)
717 v.root.QueueAllocate()
718}
719
720func (v *gtkModeToasterView) destroy() {
721 if v == nil || v.overlay == nil {
722 return
723 }
724 v.stopRefresh()
725 v.overlay.RemoveController(&v.motion.EventController)
726 v.window.RemoveController(&v.keys.EventController)
727 v.window.RemoveController(&v.windowClick.EventController)
728 if v.positionHandlerID != 0 {
729 gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(v.overlay.GoPointer()), v.positionHandlerID)
730 v.positionHandlerID = 0
731 }
732 v.placed = false
733 v.clearColumns()
734 v.releaseLegendWidgets()
735 v.overlay.RemoveOverlay(&v.pill.Widget)
736 v.overlay.RemoveOverlay(&v.root.Widget)
737
738 // Widgets are handed to us with a reference that GTK does not release on
739 // removal: drop it. (Controllers are owned by the widget they were added to,
740 // which released them on removal.) The callbacks go too, now that nothing
741 // can invoke them.
742 for _, widget := range []*gtk.Widget{
743 &v.pillLabel.Widget, &v.chevron.Widget, &v.hint.Widget, &v.columnBox.Widget,
744 &v.scroller.Widget, &v.panel.Widget, &v.pill.Widget, &v.root.Widget,
745 } {
746 widget.Unref()
747 }
748 v.pillClickCb, v.pillScrollCb, v.motionCb, v.enterCb, v.leaveCb, v.keyCb, v.windowClickC = nil, nil, nil, nil, nil, nil, nil
749 v.overlay, v.window = nil, nil
750 v.pill, v.pillLabel, v.chevron = nil, nil, nil
751 v.root, v.panel, v.scroller, v.columnBox, v.hint = nil, nil, nil, nil, nil
752 v.motion, v.keys, v.windowClick, v.pillClick, v.pillScroll = nil, nil, nil, nil, nil
753}
754
755// newModeToaster builds the mode toaster of a window inside its content
756// overlay. collapsed is the configured initial state of the legend; onToggle
757// receives the state a click on the pill asks for.
758func newModeToaster(overlay *gtk.Overlay, window *gtk.ApplicationWindow, collapsed bool, onToggle func(bool)) *modeToaster {
759 t := newModeToasterWithView(nil, onToggle)
760 t.collapsed = collapsed
761 t.cfg.ModeLegendCollapsed = collapsed
762 view := newGTKModeToasterView(overlay, window, modeToasterEvents{
763 pillClicked: t.pillClicked,
764 pointerMoved: t.pointerMoved,
765 pointerLeft: t.pointerLeft,
766 keyPressed: t.keyPressed,
767 pressedOutside: t.pressStarted,
768 })
769 if view == nil {
770 return nil
771 }
772 t.view = view
773 return t
774}