v0.10.0 d5d679d

flash.go RawHistoryFolder

1package core
2
3import (
4	"slices"
5	"time"
6
7	"github.com/bnema/neferwl/internal/ports"
8)
9
10type flashLevel uint8
11
12const (
13	flashWindow flashLevel = iota
14	flashColumn
15	flashMonitor
16	flashSettle   = 150 * time.Millisecond
17	flashRecheck  = 50 * time.Millisecond
18	flashCooldown = time.Second
19)
20
21type flashTarget struct {
22	level  flashLevel
23	window WindowID
24	output string
25}
26
27type focusFlash struct {
28	pending    *flashTarget
29	since      time.Time
30	timerC     <-chan time.Time
31	timerStop  func() bool
32	running    *flashTarget
33	rect       Rect
34	start      time.Time
35	value      float64 // Retained between this output's own flips.
36	lastStarts map[flashTarget]time.Time
37	layout     []Placement       // Owner scratch, never shared with a published scene.
38	published  *ports.FocusFlash // Immutable last sample, reused when unchanged.
39}
40
41func (c *Core) flashOn() bool { return c.animOn() && c.cfg.Focus.Flash && !c.protectionRequested() }
42
43func (c *Core) requestFlash(level flashLevel, window WindowID) {
44	if !c.flashOn() {
45		return
46	}
47	c.stopFlash()
48	f := &c.flash
49	f.pending = &flashTarget{level: level, window: window, output: c.cur().name()}
50	f.since = c.now()
51	f.timerC, f.timerStop = newTimer(c.opts.Clock, flashSettle)
52}
53
54func (c *Core) flashTick() bool {
55	f := &c.flash
56	f.timerC, f.timerStop = nil, nil
57	if f.pending == nil {
58		return false
59	}
60	target := *f.pending
61	f.pending = nil
62	if !c.flashOn() {
63		return false
64	}
65	i := c.screenIndex(target.output)
66	if i < 0 {
67		return false
68	}
69	sc := c.screens[i]
70	if sc.mon.ov.open {
71		return false
72	}
73	now := c.now()
74	if target.level != flashMonitor {
75		owner, _ := c.screenOf(target.window)
76		record := c.windows.lookup(target.window)
77		if owner != sc || record.client.ID == 0 {
78			return false
79		}
80	}
81	wait := flashSettle - now.Sub(f.since)
82	if wait > 0 || !sc.settled() {
83		if wait <= 0 {
84			wait = flashRecheck
85		}
86		f.pending = &target
87		f.timerC, f.timerStop = newTimer(c.opts.Clock, wait)
88		return false
89	}
90	rect, ok := c.flashRect(target, sc)
91	if !ok {
92		return false
93	}
94	if at, ok := f.lastStarts[target]; ok && now.Sub(at) < flashCooldown {
95		return false
96	}
97	if f.lastStarts == nil {
98		f.lastStarts = make(map[flashTarget]time.Time)
99	}
100	for key, at := range f.lastStarts {
101		if now.Sub(at) >= flashCooldown {
102			delete(f.lastStarts, key)
103		}
104	}
105	f.lastStarts[target] = now
106	f.running, f.rect, f.start = &target, rect, now
107	f.value = c.cfg.Focus.FlashOpacity
108	return true
109}
110
111func (c *Core) advanceFlash(only *screen) {
112	f := &c.flash
113	if f.running == nil {
114		return
115	}
116	if !c.flashOn() || c.screenIndex(f.running.output) < 0 {
117		c.stopFlash()
118		return
119	}
120	sc := c.screens[c.screenIndex(f.running.output)]
121	if rect, ok := c.flashRect(*f.running, sc); !ok || rect != f.rect {
122		c.stopFlash()
123		return
124	}
125	if only != nil && only.name() != f.running.output {
126		return
127	}
128	d := c.cfg.Focus.FlashDuration
129	elapsed := c.now().Sub(f.start)
130	if d <= 0 || elapsed >= d {
131		f.running = nil
132		f.value = 0
133		return
134	}
135	f.value = c.cfg.Focus.FlashOpacity * (1 - float64(max(elapsed, 0))/float64(d))
136}
137
138// flashRect resolves the current target, never retaining a border after its
139// visibility or geometry changes. stopFlash leaves the published sample intact
140// so the scene diff also damages the old border.
141func (c *Core) flashRect(target flashTarget, sc *screen) (Rect, bool) {
142	if sc.mon.ov.open {
143		return Rect{}, false
144	}
145	if target.level == flashMonitor {
146		o := sc.mon.Output()
147		return Rect{W: o.W, H: o.H}, true
148	}
149	owner, _ := c.screenOf(target.window)
150	if owner != sc || c.windows.lookup(target.window).client.ID == 0 {
151		return Rect{}, false
152	}
153	view := sc.mon.viewport()
154	c.flash.layout = sc.mon.layoutInto(c.flash.layout)
155	var rect Rect
156	found := false
157	for _, p := range c.flash.layout {
158		if p.ID == target.window {
159			if p.Fullscreen || p.Preview != 0 || !view.shows(p) {
160				return Rect{}, false
161			}
162			rect, found = p.Rect, true
163			break
164		}
165	}
166	if !found {
167		return Rect{}, false
168	}
169	if target.level == flashColumn {
170		w := sc.mon.Current()
171		col := w.columnOf(target.window)
172		if col < 0 {
173			return Rect{}, false
174		}
175		for _, p := range c.flash.layout {
176			if !p.Hidden && slices.Contains(w.Columns[col].Windows, p.ID) {
177				rect = union(rect, p.Rect)
178			}
179		}
180	}
181	return rect, true
182}
183
184func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
185	f := &c.flash
186	if !c.flashing(sc) {
187		return nil
188	}
189	color := c.cfg.Focus.FlashColor
190	if color == "" {
191		color = c.cfg.Border.Active
192	}
193	sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Color: color, Alpha: f.value}
194	if f.published == nil || *f.published != sample {
195		f.published = &ports.FocusFlash{Rect: sample.Rect, Width: sample.Width, Color: sample.Color, Alpha: sample.Alpha}
196	}
197	return f.published
198}
199
200func (c *Core) flashing(sc *screen) bool {
201	return c.flash.running != nil && c.flash.running.output == sc.name()
202}
203
204func (c *Core) stopFlashTimer() {
205	if c.flash.timerStop != nil {
206		c.flash.timerStop()
207	}
208	c.flash.timerC, c.flash.timerStop = nil, nil
209}
210
211func (c *Core) stopFlash() {
212	c.stopFlashTimer()
213	c.flash.pending, c.flash.running, c.flash.value = nil, nil, 0
214}
215
216func (m *Monitor) settled() bool {
217	if m.switchView.busy() {
218		return false
219	}
220	for w := range m.all() {
221		if w.view.busy() || w.stashView.busy() {
222			return false
223		}
224	}
225	return true
226}
227
228func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close