1package core
2
3import (
4 "time"
5
6 "github.com/bnema/neferwl/internal/ports"
7)
8
9const positionHintHold = 600 * time.Millisecond
10const positionHintFade = 150 * time.Millisecond
11
12// positionHintState belongs exclusively to the core owner. Published samples
13// are fixed-size values, so no buffer recycling or renderer acknowledgement is
14// needed. The timer is retained (but stopped when idle) and Reset on reuse.
15type positionHintState struct {
16 workspace *Workspace
17 band int
18 focused bool
19 initialized bool
20 start time.Time
21 live bool
22 fading bool
23 sample ports.PositionHint
24}
25
26type positionHintTimer struct {
27 timer ports.Timer
28 timerC <-chan time.Time
29}
30
31// updatePositionHints compares actual positions at the publication boundary,
32// independent of which input/action changed them. Initial output discovery is
33// not a move. Other outputs retain their own start time and sample.
34func (c *Core) updatePositionHints(only *screen) {
35 for i, sc := range c.screens {
36 h := &sc.positionHint
37 // Overview selection is provisional. Preserve the last committed
38 // baseline until closing, so accept compares against entry and cancel
39 // cannot announce intermediate browsing positions.
40 if sc.mon.ov.open {
41 h.live, h.sample = false, ports.PositionHint{}
42 continue
43 }
44 // Disabled hints skip the band walk; dropping the baseline keeps a
45 // later re-enable from announcing the position it finds.
46 if !c.cfg.Focus.PositionHint {
47 *h = positionHintState{}
48 continue
49 }
50 w := sc.mon.Current()
51 band := 0
52 if w.Overflow == OverflowCascade {
53 band = w.band(w.Focus)
54 }
55 focused := i == c.focusScreen
56 changed := h.initialized && (h.workspace != w || h.band != band || focused && !h.focused)
57 // Transferring the current workspace changes the source's fallback
58 // position too, but only the receiving output should announce it.
59 if h.initialized && h.workspace != w {
60 for _, other := range c.screens {
61 if other != sc && other.mon.has(h.workspace) {
62 changed = false
63 break
64 }
65 }
66 }
67 h.workspace, h.band, h.focused, h.initialized = w, band, focused, true
68 if c.protectionRequested() || w.cover() != 0 {
69 h.live, h.sample = false, ports.PositionHint{}
70 continue
71 }
72 if changed {
73 h.start, h.live, h.fading = c.now(), true, false
74 }
75 if !h.live {
76 continue
77 }
78 if !changed && only != nil && only != sc {
79 continue
80 }
81 elapsed := c.now().Sub(h.start)
82 opacity := float32(1)
83 h.fading = elapsed >= positionHintHold
84 if h.fading {
85 if !c.animOn() || elapsed >= positionHintHold+positionHintFade {
86 h.live, h.sample = false, ports.PositionHint{}
87 continue
88 }
89 opacity = 1 - float32(elapsed-positionHintHold)/float32(positionHintFade)
90 }
91 h.sample = positionHintGrid(sc.mon)
92 h.sample.Opacity = opacity
93 }
94 c.armPositionHintTimer()
95}
96
97// Use ext-workspace's complete ordered list: numbered (including empty
98// spares), then configured hidden named workspaces. Unlike overview cards,
99// these remain addressable even when empty. Window the list at its active
100// entry when it exceeds the cap. Bands stay top-aligned; excess rows clamp.
101func positionHintGrid(m *Monitor) ports.PositionHint {
102 h := ports.PositionHint{Vertical: m.overviewAxis() == verticalAxis}
103 active, total := 0, 0
104 for w := range m.all() {
105 if w == m.Current() {
106 active = total
107 }
108 total++
109 }
110 cap := ports.PositionHintMaxColumns
111 if h.Vertical {
112 cap = ports.PositionHintMaxRows
113 }
114 first := max(0, active-cap+1)
115 if h.Vertical {
116 h.Columns, h.Counts[0], h.ActiveRow = 1, uint8(min(total, cap)), uint8(active-first)
117 return h
118 }
119 h.Columns, h.ActiveCol = uint8(min(total, cap)), uint8(active-first)
120 i := 0
121 for w := range m.all() {
122 if i >= first && i < first+cap {
123 n := 1
124 if w.Overflow == OverflowCascade {
125 n, _ = w.cascadeBands()
126 }
127 h.Counts[i-first] = uint8(min(n, ports.PositionHintMaxRows))
128 }
129 i++
130 }
131 w := m.Current()
132 if w.Overflow == OverflowCascade {
133 h.ActiveRow = uint8(min(w.band(w.Focus), ports.PositionHintMaxRows-1))
134 }
135 return h
136}
137
138// positionHintFading drives fade frames through the shared scene publication,
139// like springs and the focus flash: each fade frame republishes the whole
140// scene, which allocates its slices. The hint state itself allocates nothing.
141func (c *Core) positionHintFading(sc *screen) bool {
142 return sc.positionHint.live && sc.positionHint.fading
143}
144
145// One timer wakes at the earliest hold boundary. Fade frames use the existing
146// flip/fallback scheduler; with no live hints no timer is armed or clock read.
147func (c *Core) armPositionHintTimer() {
148 var earliest time.Time
149 for _, sc := range c.screens {
150 if sc.positionHint.live && !sc.positionHint.fading {
151 end := sc.positionHint.start.Add(positionHintHold)
152 if earliest.IsZero() || end.Before(earliest) {
153 earliest = end
154 }
155 }
156 }
157 t := &c.positionHintTimer
158 if earliest.IsZero() {
159 c.stopPositionHintTimer()
160 return
161 }
162 d := max(earliest.Sub(c.now()), time.Nanosecond)
163 if t.timer == nil {
164 t.timer = newPortTimer(c.opts.Clock, d)
165 } else {
166 t.timer.Reset(d)
167 }
168 t.timerC = t.timer.C()
169}
170
171func (c *Core) stopPositionHintTimer() {
172 t := &c.positionHintTimer
173 if t.timerC != nil {
174 t.timer.Stop()
175 t.timerC = nil
176 }
177}