detached acc4897

fix(focus): flash the resulting focus on modal and drag clicks

commit
acc4897256c8104135d6dd99364e6097588f2d0b
author
brice · · 2026-10-21 14:39 UTC
parent
a448389
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
0/1
3 files · +84 -7
M internal/core/drag.go +1 -2
@@ -154,14 +154,13 @@ func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize
154154 return nil
155155 }
156156 // Click raises a float above the columns: Escape keeps that.
157- w.Click(id)
157+ c.focusClick(s, w, id)
158158 d := &dragState{id: id, button: button, resize: resize, edges: edges, start: [2]float64{c.cursorX, c.cursorY}}
159159 if i := w.floatIndex(id); i >= 0 {
160160 d.float, d.from, d.rect = true, w.Floats[i], w.floatRect(w.Floats[i])
161161 }
162162 c.drag = d
163163 c.buttons[button] = true
164- c.focusScreen = c.screenIndex(s.name())
165164 c.grab = 0
166165 if c.pointer != 0 {
167166 c.pointer = 0
@@ -154,14 +154,13 @@ func (c *Core) beginDrag(ctx context.Context, id WindowID, button uint32, resize
154 return nil154 return nil
155 }155 }
156 // Click raises a float above the columns: Escape keeps that.156 // Click raises a float above the columns: Escape keeps that.
157- w.Click(id)157+ c.focusClick(s, w, id)
158 d := &dragState{id: id, button: button, resize: resize, edges: edges, start: [2]float64{c.cursorX, c.cursorY}}158 d := &dragState{id: id, button: button, resize: resize, edges: edges, start: [2]float64{c.cursorX, c.cursorY}}
159 if i := w.floatIndex(id); i >= 0 {159 if i := w.floatIndex(id); i >= 0 {
160 d.float, d.from, d.rect = true, w.Floats[i], w.floatRect(w.Floats[i])160 d.float, d.from, d.rect = true, w.Floats[i], w.floatRect(w.Floats[i])
161 }161 }
162 c.drag = d162 c.drag = d
163 c.buttons[button] = true163 c.buttons[button] = true
164- c.focusScreen = c.screenIndex(s.name())
165 c.grab = 0164 c.grab = 0
166 if c.pointer != 0 {165 if c.pointer != 0 {
167 c.pointer = 0166 c.pointer = 0
M internal/core/flash_test.go +71 -0
@@ -812,3 +812,74 @@ func TestFlashClickFocus(t *testing.T) {
812812 })
813813 }
814814 }
815+
816+func TestFlashClickModalFocus(t *testing.T) {
817+ for _, unchanged := range []bool{false, true} {
818+ t.Run(map[bool]string{false: "changed", true: "unchanged"}[unchanged], func(t *testing.T) {
819+ c, _ := flashCore(t)
820+ w := c.cur().mon.Current()
821+ c.windows.mapped(ports.WindowMapped{ID: 4}, c.now())
822+ w.AddDialog(4, 1, 100, 80)
823+ w.SetModal(4, true)
824+ if !unchanged {
825+ w.Click(2)
826+ }
827+ indicatorScene(t, c)
828+ c.pointer = 1
829+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
830+ t.Fatal(err)
831+ }
832+ if c.keyboardFocus() != 4 {
833+ t.Fatalf("focus = %d, want modal 4", c.keyboardFocus())
834+ }
835+ if unchanged {
836+ if c.flash.pending != nil {
837+ t.Fatalf("unchanged modal flashed: %+v", c.flash.pending)
838+ }
839+ } else if c.flash.pending == nil || c.flash.pending.window != 4 {
840+ t.Fatalf("want modal flash, got %+v", c.flash.pending)
841+ }
842+ })
843+ }
844+}
845+
846+func TestFlashModifierClickDrag(t *testing.T) {
847+ for _, resize := range []bool{false, true} {
848+ for _, mode := range []string{"changed", "unchanged", "disabled"} {
849+ t.Run(map[bool]string{false: "move", true: "resize"}[resize]+"/"+mode, func(t *testing.T) {
850+ c, clock := flashCore(t)
851+ w := c.cur().mon.Current()
852+ w.Click(1)
853+ if resize {
854+ w.Apply(ActionToggleFloating)
855+ }
856+ if mode != "unchanged" {
857+ w.Click(3)
858+ }
859+ c.cfg.Focus.Flash = mode != "disabled"
860+ indicatorScene(t, c)
861+ c.pointer = 1
862+ c.mods = c.cmdMod
863+ button := uint32(btnLeft)
864+ if resize {
865+ button = btnRight
866+ }
867+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: button, Pressed: true}); err != nil {
868+ t.Fatal(err)
869+ }
870+ if c.drag == nil || c.drag.id != 1 || c.drag.resize != resize {
871+ t.Fatalf("drag not started: %+v", c.drag)
872+ }
873+ want := mode == "changed"
874+ if (c.flash.pending != nil) != want {
875+ t.Fatalf("flash = %+v, want %v", c.flash.pending, want)
876+ }
877+ clock.now = clock.now.Add(time.Second)
878+ stepScenes(t, c, c.cur())
879+ if c.flashTick() != want {
880+ t.Fatalf("flash start: want %v", want)
881+ }
882+ })
883+ }
884+ }
885+}
@@ -812,3 +812,74 @@ func TestFlashClickFocus(t *testing.T) {
812 })812 })
813 }813 }
814 }814 }
815+
816+func TestFlashClickModalFocus(t *testing.T) {
817+ for _, unchanged := range []bool{false, true} {
818+ t.Run(map[bool]string{false: "changed", true: "unchanged"}[unchanged], func(t *testing.T) {
819+ c, _ := flashCore(t)
820+ w := c.cur().mon.Current()
821+ c.windows.mapped(ports.WindowMapped{ID: 4}, c.now())
822+ w.AddDialog(4, 1, 100, 80)
823+ w.SetModal(4, true)
824+ if !unchanged {
825+ w.Click(2)
826+ }
827+ indicatorScene(t, c)
828+ c.pointer = 1
829+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
830+ t.Fatal(err)
831+ }
832+ if c.keyboardFocus() != 4 {
833+ t.Fatalf("focus = %d, want modal 4", c.keyboardFocus())
834+ }
835+ if unchanged {
836+ if c.flash.pending != nil {
837+ t.Fatalf("unchanged modal flashed: %+v", c.flash.pending)
838+ }
839+ } else if c.flash.pending == nil || c.flash.pending.window != 4 {
840+ t.Fatalf("want modal flash, got %+v", c.flash.pending)
841+ }
842+ })
843+ }
844+}
845+
846+func TestFlashModifierClickDrag(t *testing.T) {
847+ for _, resize := range []bool{false, true} {
848+ for _, mode := range []string{"changed", "unchanged", "disabled"} {
849+ t.Run(map[bool]string{false: "move", true: "resize"}[resize]+"/"+mode, func(t *testing.T) {
850+ c, clock := flashCore(t)
851+ w := c.cur().mon.Current()
852+ w.Click(1)
853+ if resize {
854+ w.Apply(ActionToggleFloating)
855+ }
856+ if mode != "unchanged" {
857+ w.Click(3)
858+ }
859+ c.cfg.Focus.Flash = mode != "disabled"
860+ indicatorScene(t, c)
861+ c.pointer = 1
862+ c.mods = c.cmdMod
863+ button := uint32(btnLeft)
864+ if resize {
865+ button = btnRight
866+ }
867+ if err := c.pointerButton(context.Background(), ports.PointerButton{Button: button, Pressed: true}); err != nil {
868+ t.Fatal(err)
869+ }
870+ if c.drag == nil || c.drag.id != 1 || c.drag.resize != resize {
871+ t.Fatalf("drag not started: %+v", c.drag)
872+ }
873+ want := mode == "changed"
874+ if (c.flash.pending != nil) != want {
875+ t.Fatalf("flash = %+v, want %v", c.flash.pending, want)
876+ }
877+ clock.now = clock.now.Add(time.Second)
878+ stepScenes(t, c, c.cur())
879+ if c.flashTick() != want {
880+ t.Fatalf("flash start: want %v", want)
881+ }
882+ })
883+ }
884+ }
885+}
M internal/core/input.go +12 -5
@@ -134,16 +134,23 @@ func (c *Core) clickFocus(ctx context.Context, id WindowID) error {
134134 if s == nil || w != s.mon.Current() || (c.keyboard.sent == id && s == c.cur()) {
135135 return nil
136136 }
137- focused, _ := c.cur().mon.Focused()
138137 now := c.now()
139138 before := c.snapshot(now)
139+ c.focusClick(s, w, id)
140+ c.transition(before, now)
141+ return c.publish(ctx)
142+}
143+
144+// focusClick raises the clicked window and focuses its output. Modal dialogs
145+// can take the parent's focus, so flash the resolved focus, not the click target.
146+func (c *Core) focusClick(s *screen, w *Workspace, id WindowID) {
147+ before, _ := c.cur().mon.Focused()
140148 w.Click(id)
141149 c.focusScreen = c.screenIndex(s.name())
142- c.transition(before, now)
143- if focused != id {
144- c.requestFlash(flashWindow, id)
150+ after, _ := c.cur().mon.Focused()
151+ if after != 0 && after != before {
152+ c.requestFlash(flashWindow, after)
145153 }
146- return c.publish(ctx)
147154 }
148155
149156 func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
@@ -134,16 +134,23 @@ func (c *Core) clickFocus(ctx context.Context, id WindowID) error {
134 if s == nil || w != s.mon.Current() || (c.keyboard.sent == id && s == c.cur()) {134 if s == nil || w != s.mon.Current() || (c.keyboard.sent == id && s == c.cur()) {
135 return nil135 return nil
136 }136 }
137- focused, _ := c.cur().mon.Focused()
138 now := c.now()137 now := c.now()
139 before := c.snapshot(now)138 before := c.snapshot(now)
139+ c.focusClick(s, w, id)
140+ c.transition(before, now)
141+ return c.publish(ctx)
142+}
143+
144+// focusClick raises the clicked window and focuses its output. Modal dialogs
145+// can take the parent's focus, so flash the resolved focus, not the click target.
146+func (c *Core) focusClick(s *screen, w *Workspace, id WindowID) {
147+ before, _ := c.cur().mon.Focused()
140 w.Click(id)148 w.Click(id)
141 c.focusScreen = c.screenIndex(s.name())149 c.focusScreen = c.screenIndex(s.name())
142- c.transition(before, now)150+ after, _ := c.cur().mon.Focused()
143- if focused != id {151+ if after != 0 && after != before {
144- c.requestFlash(flashWindow, id)152+ c.requestFlash(flashWindow, after)
145 }153 }
146- return c.publish(ctx)
147 }154 }
148 155
149 func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {156 func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {

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 25

Tags 10

↑↓ moveenter openesc close