detached 608a5b9

#11 feat(render): full borders with gaps and rounded corners

state
merged · 608a5b9 · fast-forward
branch
feat/gap-borders → main
opened
bnema ·
updated

Summary

  • layout.gaps = 0: tmux-style lines between tiles, unchanged.
  • layout.gaps > 0: every tile owns a full border (border.width, border.active / border.inactive), even alone.
  • New border.radius (0–64, default 0, reloadable): rounds floats always and tiles with gaps; fullscreen, popups and layers stay square.
  • Rounded corners are done in the compose shader with an SDF coverage mask (flagRounded / flagRing); square windows pay one branch. The border is one ring draw.
  • Rounded windows are never opaque occluders, direct-scanned out or put on overlay planes.
  • README no longer rules out rounded corners; docs/config.md and examples/config list border.radius.

Checks

make check, make race, make mocks-check, staticcheck, make spv-check; headless e2e with gaps 8 / radius 10 (borders, AA corner, overview) and gaps 0 (tmux line unchanged).

Not verified

Real DRM direct scanout on hardware.

CommitSubjectAuthorDateSignatureStatus
608a5b9refactor(render): tidy gap borders and rounded corner handlingbriceGPG Verified
fd770f2feat(render): add full borders with gaps and rounded cornersbriceGPG Verified

Checks 2

39 files · +452 -52
1/2
M README.md +1 -1
M docs/config.md +3 -0
M examples/config +2 -0
M internal/adapters/config/config.go +6 -0
M internal/adapters/config/config_test.go +18 -0
M internal/adapters/config/watch_test.go +3 -3
M internal/adapters/drm/overlay.go +1 -1
M internal/adapters/drm/overlay_test.go +9 -0
M internal/adapters/drm/scanout.go +1 -1
M internal/adapters/drm/scanout_test.go +1 -0
M internal/adapters/vulkan/compose.go +8 -4
M internal/adapters/vulkan/compose_batch.go +5 -4
M internal/adapters/vulkan/compose_batch_test.go +1 -1
M internal/adapters/vulkan/damage_delta_test.go +1 -1
M internal/adapters/vulkan/orient.go +6 -0
A internal/adapters/vulkan/orient_rounded_test.go +51 -0
M internal/adapters/vulkan/scene.go +81 -5
M internal/adapters/vulkan/scene_fade_test.go +49 -0
M internal/adapters/vulkan/scene_opaque_region_test.go +8 -0
M internal/adapters/vulkan/shaders/compose.frag +18 -0
M internal/adapters/vulkan/shaders/compose.frag.spv +0 -0 binary file: no text diff
M internal/adapters/vulkan/shaders/compose.vert +9 -1
M internal/adapters/vulkan/shaders/compose.vert.spv +0 -0 binary file: no text diff
M internal/adapters/vulkan/shaders/compose_hdr.frag +18 -0
M internal/adapters/vulkan/shaders/compose_hdr.frag.spv +0 -0 binary file: no text diff
M internal/adapters/vulkan/shaders/compose_hdr_single.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/shaders/compose_single.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/core/border_invariants_test.go +65 -5
@@ -207,7 +207,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
207207 }
208208 }
209209 }
210- if tiles <= 1 {
210+ if tiles <= 1 && gap == 0 {
211211 for _, w := range s.Windows {
212212 if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 {
213213 t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s)
@@ -222,7 +222,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
222222 if sep.Window != 0 {
223223 found := false
224224 for _, w := range s.Windows {
225- if w.ID == sep.Window && w.Floating && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {
225+ if w.ID == sep.Window && (w.Floating || gap > 0) && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {
226226 found = true
227227 }
228228 }
@@ -243,7 +243,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
243243 }
244244 near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4}
245245 for _, sep := range s.Separators {
246- if sep.Active && sep.Window == 0 && sep.Rect.Overlaps(near) {
246+ if sep.Active && (sep.Window == 0 || gap > 0 && sep.Window == focus.ID) && sep.Rect.Overlaps(near) {
247247 return
248248 }
249249 }
@@ -276,9 +276,12 @@ func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
276276 return sides
277277 }
278278
279-// wantInset is the exact inset of a visible tile: every neighbor side with
280-// gaps, only the right and bottom (shared-line owner) sides without.
279+// wantInset is the exact inset of a visible tile: all sides with gaps,
280+// only the right and bottom shared-line owner sides without.
281281 func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
282+ if gap > 0 {
283+ return ports.SideAll
284+ }
282285 sides := neighborSides(s, w, gap)
283286 if gap == 0 {
284287 sides &= ports.SideRight | ports.SideBottom
@@ -304,3 +307,60 @@ func TestBorderMoveBackAlone(t *testing.T) {
304307 }
305308 }
306309 }
310+
311+func TestBorderRadiusScene(t *testing.T) {
312+ for _, gap := range []int{0, 8} {
313+ t.Run(map[int]string{0: "flush", 8: "gaps"}[gap], func(t *testing.T) {
314+ r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = gap; c.Border.Radius = 10 }, left, right)
315+ scenes := r.mapWindow(t, 1)
316+ for _, s := range scenes {
317+ for _, w := range s.Windows {
318+ if w.ID == 1 {
319+ want := 0
320+ if gap > 0 {
321+ want = 10
322+ }
323+ if w.Radius != want || s.Border.Radius != 10 {
324+ t.Fatalf("radius %d, style %d", w.Radius, s.Border.Radius)
325+ }
326+ }
327+ }
328+ }
329+ for _, action := range []struct {
330+ sym string
331+ mods ports.Mods
332+ want int
333+ }{
334+ {"space", ports.ModAlt | ports.ModShift, 10},
335+ {"f", ports.ModAlt | ports.ModShift, 0},
336+ } {
337+ for _, s := range r.key(t, action.sym, action.mods) {
338+ for _, w := range s.Windows {
339+ if w.ID == 1 && w.Radius != action.want {
340+ t.Fatalf("%s radius %d, want %d", action.sym, w.Radius, action.want)
341+ }
342+ }
343+ }
344+ }
345+ })
346+ }
347+}
348+
349+// A reload that only changes border.radius republishes the scene with the
350+// new radius.
351+func TestReloadBorderRadiusRepublishes(t *testing.T) {
352+ r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 8; c.Border.Radius = 4 }, left)
353+ r.mapWindow(t, 1)
354+ cfg := r.cfg
355+ cfg.Border.Radius = 12
356+ r.reload <- ports.ConfigChanged{Config: cfg}
357+ s := scene(t, r.scenes)
358+ if s.Border.Radius != 12 {
359+ t.Fatalf("style radius %d, want 12", s.Border.Radius)
360+ }
361+ for _, w := range s.Windows {
362+ if w.ID == 1 && w.Radius != 12 {
363+ t.Fatalf("window radius %d, want 12", w.Radius)
364+ }
365+ }
366+}
@@ -207,7 +207,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
207 }207 }
208 }208 }
209 }209 }
210- if tiles <= 1 {210+ if tiles <= 1 && gap == 0 {
211 for _, w := range s.Windows {211 for _, w := range s.Windows {
212 if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 {212 if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 {
213 t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s)213 t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s)
@@ -222,7 +222,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
222 if sep.Window != 0 {222 if sep.Window != 0 {
223 found := false223 found := false
224 for _, w := range s.Windows {224 for _, w := range s.Windows {
225- if w.ID == sep.Window && w.Floating && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {225+ if w.ID == sep.Window && (w.Floating || gap > 0) && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {
226 found = true226 found = true
227 }227 }
228 }228 }
@@ -243,7 +243,7 @@ func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
243 }243 }
244 near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4}244 near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4}
245 for _, sep := range s.Separators {245 for _, sep := range s.Separators {
246- if sep.Active && sep.Window == 0 && sep.Rect.Overlaps(near) {246+ if sep.Active && (sep.Window == 0 || gap > 0 && sep.Window == focus.ID) && sep.Rect.Overlaps(near) {
247 return247 return
248 }248 }
249 }249 }
@@ -276,9 +276,12 @@ func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
276 return sides276 return sides
277 }277 }
278 278
279-// wantInset is the exact inset of a visible tile: every neighbor side with279+// wantInset is the exact inset of a visible tile: all sides with gaps,
280-// gaps, only the right and bottom (shared-line owner) sides without.280+// only the right and bottom shared-line owner sides without.
281 func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {281 func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
282+ if gap > 0 {
283+ return ports.SideAll
284+ }
282 sides := neighborSides(s, w, gap)285 sides := neighborSides(s, w, gap)
283 if gap == 0 {286 if gap == 0 {
284 sides &= ports.SideRight | ports.SideBottom287 sides &= ports.SideRight | ports.SideBottom
@@ -304,3 +307,60 @@ func TestBorderMoveBackAlone(t *testing.T) {
304 }307 }
305 }308 }
306 }309 }
310+
311+func TestBorderRadiusScene(t *testing.T) {
312+ for _, gap := range []int{0, 8} {
313+ t.Run(map[int]string{0: "flush", 8: "gaps"}[gap], func(t *testing.T) {
314+ r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = gap; c.Border.Radius = 10 }, left, right)
315+ scenes := r.mapWindow(t, 1)
316+ for _, s := range scenes {
317+ for _, w := range s.Windows {
318+ if w.ID == 1 {
319+ want := 0
320+ if gap > 0 {
321+ want = 10
322+ }
323+ if w.Radius != want || s.Border.Radius != 10 {
324+ t.Fatalf("radius %d, style %d", w.Radius, s.Border.Radius)
325+ }
326+ }
327+ }
328+ }
329+ for _, action := range []struct {
330+ sym string
331+ mods ports.Mods
332+ want int
333+ }{
334+ {"space", ports.ModAlt | ports.ModShift, 10},
335+ {"f", ports.ModAlt | ports.ModShift, 0},
336+ } {
337+ for _, s := range r.key(t, action.sym, action.mods) {
338+ for _, w := range s.Windows {
339+ if w.ID == 1 && w.Radius != action.want {
340+ t.Fatalf("%s radius %d, want %d", action.sym, w.Radius, action.want)
341+ }
342+ }
343+ }
344+ }
345+ })
346+ }
347+}
348+
349+// A reload that only changes border.radius republishes the scene with the
350+// new radius.
351+func TestReloadBorderRadiusRepublishes(t *testing.T) {
352+ r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 8; c.Border.Radius = 4 }, left)
353+ r.mapWindow(t, 1)
354+ cfg := r.cfg
355+ cfg.Border.Radius = 12
356+ r.reload <- ports.ConfigChanged{Config: cfg}
357+ s := scene(t, r.scenes)
358+ if s.Border.Radius != 12 {
359+ t.Fatalf("style radius %d, want 12", s.Border.Radius)
360+ }
361+ for _, w := range s.Windows {
362+ if w.ID == 1 && w.Radius != 12 {
363+ t.Fatalf("window radius %d, want 12", w.Radius)
364+ }
365+ }
366+}
M internal/core/capture_window.go +1 -1
@@ -53,7 +53,7 @@ func (c *Core) captureWindowScene(v *capView, seq uint64) *ports.Scene {
5353 Security: c.security,
5454 Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H},
5555 Scale: v.windowScr.scale, Background: c.cfg.Background.Color,
56- Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
56+ Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
5757 Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}},
5858 }
5959 for _, id := range c.popupOrder {
@@ -53,7 +53,7 @@ func (c *Core) captureWindowScene(v *capView, seq uint64) *ports.Scene {
53 Security: c.security,53 Security: c.security,
54 Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H},54 Output: v.windowScr.name(), Seq: seq, OutputWidth: sz.W, OutputHeight: sz.H, WorkspaceClip: Rect{W: sz.W, H: sz.H},
55 Scale: v.windowScr.scale, Background: c.cfg.Background.Color,55 Scale: v.windowScr.scale, Background: c.cfg.Background.Color,
56- Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},56+ Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
57 Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}},57 Windows: []ports.SceneWindow{{ID: v.window, Rect: Rect{W: sz.W, H: sz.H}, Fullscreen: true}},
58 }58 }
59 for _, id := range c.popupOrder {59 for _, id := range c.popupOrder {
M internal/core/capture_workspace.go +2 -1
@@ -153,13 +153,14 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
153153 Security: c.security,
154154 Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H},
155155 Scale: cw.sc.scale, Background: c.cfg.Background.Color,
156- Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
156+ Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
157157 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),
158158 Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim),
159159 }
160160 // A hidden workspace lights nothing: its focus is not shown.
161161 for _, p := range cw.placements {
162162 w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview}
163+ w.Radius = windowRadius(p, cw.ws.gap(), c.cfg.Border.Radius)
163164 if !p.Hidden {
164165 w.Rect = rebase(p.Rect)
165166 }
@@ -153,13 +153,14 @@ func (c *Core) captureScene(seq uint64) *ports.Scene {
153 Security: c.security,153 Security: c.security,
154 Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H},154 Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H},
155 Scale: cw.sc.scale, Background: c.cfg.Background.Color,155 Scale: cw.sc.scale, Background: c.cfg.Background.Color,
156- Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},156+ Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
157 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),157 Windows: make([]ports.SceneWindow, 0, len(cw.placements)),
158 Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim),158 Dim: floatDim(cw.placements, view, c.cfg.Floating.Dim),
159 }159 }
160 // A hidden workspace lights nothing: its focus is not shown.160 // A hidden workspace lights nothing: its focus is not shown.
161 for _, p := range cw.placements {161 for _, p := range cw.placements {
162 w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview}162 w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview}
163+ w.Radius = windowRadius(p, cw.ws.gap(), c.cfg.Border.Radius)
163 if !p.Hidden {164 if !p.Hidden {
164 w.Rect = rebase(p.Rect)165 w.Rect = rebase(p.Rect)
165 }166 }
M internal/core/capture_workspace_test.go +31 -0
@@ -270,3 +270,34 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) {
270270 }
271271 }
272272 }
273+
274+// The workspace capture scene carries the corner radius like the live one:
275+// a tile with gaps and a float are rounded.
276+func TestCaptureSceneRadius(t *testing.T) {
277+ c, ws := hiddenCapture(t)
278+ c.cfg.Border.Radius = 9
279+ ws.Gaps = 4
280+ ws.AddFloating(3, 20, 20)
281+ c.configures.cw.load(c.screens[0], ws)
282+ s := c.captureScene(1)
283+ if s == nil || s.Border.Radius != 9 {
284+ t.Fatalf("scene %+v", s)
285+ }
286+ var tiles, floats int
287+ for _, w := range s.Windows {
288+ if w.Popup {
289+ continue
290+ }
291+ if w.Radius != 9 {
292+ t.Fatalf("window %d (floating %v) radius %d, want 9", w.ID, w.Floating, w.Radius)
293+ }
294+ if w.Floating {
295+ floats++
296+ } else {
297+ tiles++
298+ }
299+ }
300+ if tiles == 0 || floats == 0 {
301+ t.Fatalf("tiles %d floats %d", tiles, floats)
302+ }
303+}
@@ -270,3 +270,34 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) {
270 }270 }
271 }271 }
272 }272 }
273+
274+// The workspace capture scene carries the corner radius like the live one:
275+// a tile with gaps and a float are rounded.
276+func TestCaptureSceneRadius(t *testing.T) {
277+ c, ws := hiddenCapture(t)
278+ c.cfg.Border.Radius = 9
279+ ws.Gaps = 4
280+ ws.AddFloating(3, 20, 20)
281+ c.configures.cw.load(c.screens[0], ws)
282+ s := c.captureScene(1)
283+ if s == nil || s.Border.Radius != 9 {
284+ t.Fatalf("scene %+v", s)
285+ }
286+ var tiles, floats int
287+ for _, w := range s.Windows {
288+ if w.Popup {
289+ continue
290+ }
291+ if w.Radius != 9 {
292+ t.Fatalf("window %d (floating %v) radius %d, want 9", w.ID, w.Floating, w.Radius)
293+ }
294+ if w.Floating {
295+ floats++
296+ } else {
297+ tiles++
298+ }
299+ }
300+ if tiles == 0 || floats == 0 {
301+ t.Fatalf("tiles %d floats %d", tiles, floats)
302+ }
303+}
M internal/core/core_test.go +1 -1
@@ -124,7 +124,7 @@ func owner(t *testing.T, animated bool) {
124124 client <- ports.WindowMapped{ID: 1}
125125 s = l.one(scene(t, scenes))
126126 // A single column fills the usable width.
127- if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
127+ if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != ports.SideAll {
128128 t.Fatal(s)
129129 }
130130 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1", Visible: true}) {
@@ -124,7 +124,7 @@ func owner(t *testing.T, animated bool) {
124 client <- ports.WindowMapped{ID: 1}124 client <- ports.WindowMapped{ID: 1}
125 s = l.one(scene(t, scenes))125 s = l.one(scene(t, scenes))
126 // A single column fills the usable width.126 // A single column fills the usable width.
127- if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {127+ if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != ports.SideAll {
128 t.Fatal(s)128 t.Fatal(s)
129 }129 }
130 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1", Visible: true}) {130 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1", Visible: true}) {
M internal/core/layout.go +5 -2
@@ -60,8 +60,9 @@ type Placement struct {
6060 // that much smaller in Rect, the client keeps its size. 0 otherwise.
6161 Preview float64
6262 // Neighbors are the sides touching a tile visible in the usable area.
63- // Inset reserves room for drawn lines: all sides of a float; for
64- // tiles without gaps, only the right and bottom shared sides.
63+ // Inset reserves room for drawn lines: all sides of a float; all sides
64+ // of a tile with gaps; only the right and bottom shared sides of a tile
65+ // without gaps.
6566 Neighbors, Inset ports.Sides
6667 // Leaving marks a window that closed or hid but is still drawn while
6768 // it fades out (transition.go). It stays Hidden, so input, focus,
@@ -1988,6 +1989,8 @@ func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
19881989 a.Inset = a.Neighbors
19891990 if gap == 0 {
19901991 a.Inset &= ports.SideRight | ports.SideBottom
1992+ } else {
1993+ a.Inset = ports.SideAll
19911994 }
19921995 }
19931996 }
@@ -60,8 +60,9 @@ type Placement struct {
60 // that much smaller in Rect, the client keeps its size. 0 otherwise.60 // that much smaller in Rect, the client keeps its size. 0 otherwise.
61 Preview float6461 Preview float64
62 // Neighbors are the sides touching a tile visible in the usable area.62 // Neighbors are the sides touching a tile visible in the usable area.
63- // Inset reserves room for drawn lines: all sides of a float; for63+ // Inset reserves room for drawn lines: all sides of a float; all sides
64- // tiles without gaps, only the right and bottom shared sides.64+ // of a tile with gaps; only the right and bottom shared sides of a tile
65+ // without gaps.
65 Neighbors, Inset ports.Sides66 Neighbors, Inset ports.Sides
66 // Leaving marks a window that closed or hid but is still drawn while67 // Leaving marks a window that closed or hid but is still drawn while
67 // it fades out (transition.go). It stays Hidden, so input, focus,68 // it fades out (transition.go). It stays Hidden, so input, focus,
@@ -1988,6 +1989,8 @@ func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
1988 a.Inset = a.Neighbors1989 a.Inset = a.Neighbors
1989 if gap == 0 {1990 if gap == 0 {
1990 a.Inset &= ports.SideRight | ports.SideBottom1991 a.Inset &= ports.SideRight | ports.SideBottom
1992+ } else {
1993+ a.Inset = ports.SideAll
1991 }1994 }
1992 }1995 }
1993 }1996 }
M internal/core/layout_test.go +1 -1
@@ -151,7 +151,7 @@ func TestStackAndLayout(t *testing.T) {
151151 }
152152 w.FocusWindow(-1)
153153 p := w.Layout()
154- if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideTop {
154+ if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideAll {
155155 t.Fatal(p)
156156 }
157157 w.ToggleFullscreen()
@@ -151,7 +151,7 @@ func TestStackAndLayout(t *testing.T) {
151 }151 }
152 w.FocusWindow(-1)152 w.FocusWindow(-1)
153 p := w.Layout()153 p := w.Layout()
154- if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideTop {154+ if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideAll {
155 t.Fatal(p)155 t.Fatal(p)
156 }156 }
157 w.ToggleFullscreen()157 w.ToggleFullscreen()
M internal/core/scene.go +2 -1
@@ -20,7 +20,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
2020 // layout is what is drawn; settled (same indexes) is where the
2121 // windows are going, and alone sizes the configures.
2222 layout, settled := sc.shown, sc.settledLayout
23- scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}
23+ scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}
2424 if !sc.mon.previewing() {
2525 // Tiles stop at the panels; the overview lays out previews over
2626 // the whole output.
@@ -72,6 +72,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
7272 // Only the focused output has an activated window.
7373 focused := p.Focused && i == c.focusScreen
7474 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
75+ sw.Radius = windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius)
7576 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
7677 // A scale motion: the content follows the drawn size. A
7778 // card in flight drawn at its size still needs Zoom 1:
@@ -20,7 +20,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
20 // layout is what is drawn; settled (same indexes) is where the20 // layout is what is drawn; settled (same indexes) is where the
21 // windows are going, and alone sizes the configures.21 // windows are going, and alone sizes the configures.
22 layout, settled := sc.shown, sc.settledLayout22 layout, settled := sc.shown, sc.settledLayout
23- scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}23+ scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}
24 if !sc.mon.previewing() {24 if !sc.mon.previewing() {
25 // Tiles stop at the panels; the overview lays out previews over25 // Tiles stop at the panels; the overview lays out previews over
26 // the whole output.26 // the whole output.
@@ -72,6 +72,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
72 // Only the focused output has an activated window.72 // Only the focused output has an activated window.
73 focused := p.Focused && i == c.focusScreen73 focused := p.Focused && i == c.focusScreen
74 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}74 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
75+ sw.Radius = windowRadius(p, sc.mon.Current().gap(), c.cfg.Border.Radius)
75 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {76 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
76 // A scale motion: the content follows the drawn size. A77 // A scale motion: the content follows the drawn size. A
77 // card in flight drawn at its size still needs Zoom 1:78 // card in flight drawn at its size still needs Zoom 1:
M internal/core/separators.go +29 -13
@@ -2,17 +2,18 @@ package core
22
33 import "github.com/bnema/neferwl/internal/ports"
44
5-// Like tmux screen-redraw.c, lines separate visible panes, never outline a
6-// lone tile or a fullscreen/zoomed tile. Adjacent tiles share one line,
7-// owned (and inset) by the left/top tile; focus lights only the adjacent
8-// sides of the focused tile, including corners. With exactly two visible
9-// tiles and no gaps, each lights its own half of the shared line (left/top
10-// first). With gaps, each tile owns its neighbor-facing lines and lights
11-// them whole. Hidden, scrolled-off, under-panel, other-workspace and
5+// Two modes. Without gaps (gap 0), borders are tmux-style lines (like tmux
6+// screen-redraw.c): lines separate visible panes, never outline a lone tile
7+// or a fullscreen/zoomed tile. Adjacent tiles share one line, owned (and
8+// inset) by the left/top tile; focus lights only the adjacent sides of the
9+// focused tile, including corners. With exactly two visible tiles, each
10+// lights its own half of the shared line (left/top first). With gaps, each
11+// tile owns a full four-sided border, like a float, and lights it whole,
12+// even when alone. Hidden, scrolled-off, under-panel, other-workspace and
1213 // other-output tiles do not count, nor do tiles that share no line. Lines
13-// at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles:
14-// each gets its own full border, drawn with that window (Separator.Window),
15-// even when it is the only visible window. Only the
14+// at the edges of the usable area (output or panels) are omitted. Floating
15+// windows also get their own full border, drawn with that window
16+// (Separator.Window), even when it is the only visible window. Only the
1617 // focused output's focused window can light lines; keyboard grabs by popups
1718 // and layers do not change the underlying window's visual focus.
1819
@@ -39,7 +40,7 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
3940 }
4041 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
4142 var id WindowID
42- if p.Floating {
43+ if p.Floating || gap > 0 {
4344 id = p.ID
4445 }
4546 if out == nil {
@@ -58,6 +59,12 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
5859 if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
5960 continue
6061 }
62+ if gap > 0 {
63+ for _, s := range sides {
64+ add(p, strip(p.Rect, s, 0, width), p.Focused && lit)
65+ }
66+ continue
67+ }
6168 // Tiles sharing a line: Neighbors only counts tiles visible in
6269 // the usable area, not those under a panel.
6370 if !p.Floating && p.Neighbors != 0 {
@@ -91,8 +98,8 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
9198 continue
9299 }
93100 line = closeCorners(line, *f, s, width)
94- // Two tiles share one line only without gaps.
95- if tiles == 2 && gap == 0 {
101+ // With gaps the loop above continued, so two tiles share one line.
102+ if tiles == 2 {
96103 line = half(line, s)
97104 }
98105 add(f, line, true)
@@ -152,3 +159,12 @@ func half(r Rect, s ports.Sides) Rect {
152159 }
153160 return r
154161 }
162+
163+// windowRadius is the corner radius of p: floats always, tiles only with
164+// gaps, never fullscreen windows.
165+func windowRadius(p Placement, gap, radius int) int {
166+ if !p.Fullscreen && (p.Floating || gap > 0) {
167+ return radius
168+ }
169+ return 0
170+}
@@ -2,17 +2,18 @@ package core
2 2
3 import "github.com/bnema/neferwl/internal/ports"3 import "github.com/bnema/neferwl/internal/ports"
4 4
5-// Like tmux screen-redraw.c, lines separate visible panes, never outline a5+// Two modes. Without gaps (gap 0), borders are tmux-style lines (like tmux
6-// lone tile or a fullscreen/zoomed tile. Adjacent tiles share one line,6+// screen-redraw.c): lines separate visible panes, never outline a lone tile
7-// owned (and inset) by the left/top tile; focus lights only the adjacent7+// or a fullscreen/zoomed tile. Adjacent tiles share one line, owned (and
8-// sides of the focused tile, including corners. With exactly two visible8+// inset) by the left/top tile; focus lights only the adjacent sides of the
9-// tiles and no gaps, each lights its own half of the shared line (left/top9+// focused tile, including corners. With exactly two visible tiles, each
10-// first). With gaps, each tile owns its neighbor-facing lines and lights10+// lights its own half of the shared line (left/top first). With gaps, each
11-// them whole. Hidden, scrolled-off, under-panel, other-workspace and11+// tile owns a full four-sided border, like a float, and lights it whole,
12+// even when alone. Hidden, scrolled-off, under-panel, other-workspace and
12 // other-output tiles do not count, nor do tiles that share no line. Lines13 // other-output tiles do not count, nor do tiles that share no line. Lines
13-// at the edges of the usable area (output or panels) are omitted. Floating windows differ from tmux tiles:14+// at the edges of the usable area (output or panels) are omitted. Floating
14-// each gets its own full border, drawn with that window (Separator.Window),15+// windows also get their own full border, drawn with that window
15-// even when it is the only visible window. Only the16+// (Separator.Window), even when it is the only visible window. Only the
16 // focused output's focused window can light lines; keyboard grabs by popups17 // focused output's focused window can light lines; keyboard grabs by popups
17 // and layers do not change the underlying window's visual focus.18 // and layers do not change the underlying window's visual focus.
18 19
@@ -39,7 +40,7 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
39 }40 }
40 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}41 r = Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
41 var id WindowID42 var id WindowID
42- if p.Floating {43+ if p.Floating || gap > 0 {
43 id = p.ID44 id = p.ID
44 }45 }
45 if out == nil {46 if out == nil {
@@ -58,6 +59,12 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
58 if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {59 if p.Fullscreen || !(v.shows(*p) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
59 continue60 continue
60 }61 }
62+ if gap > 0 {
63+ for _, s := range sides {
64+ add(p, strip(p.Rect, s, 0, width), p.Focused && lit)
65+ }
66+ continue
67+ }
61 // Tiles sharing a line: Neighbors only counts tiles visible in68 // Tiles sharing a line: Neighbors only counts tiles visible in
62 // the usable area, not those under a panel.69 // the usable area, not those under a panel.
63 if !p.Floating && p.Neighbors != 0 {70 if !p.Floating && p.Neighbors != 0 {
@@ -91,8 +98,8 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
91 continue98 continue
92 }99 }
93 line = closeCorners(line, *f, s, width)100 line = closeCorners(line, *f, s, width)
94- // Two tiles share one line only without gaps.101+ // With gaps the loop above continued, so two tiles share one line.
95- if tiles == 2 && gap == 0 {102+ if tiles == 2 {
96 line = half(line, s)103 line = half(line, s)
97 }104 }
98 add(f, line, true)105 add(f, line, true)
@@ -152,3 +159,12 @@ func half(r Rect, s ports.Sides) Rect {
152 }159 }
153 return r160 return r
154 }161 }
162+
163+// windowRadius is the corner radius of p: floats always, tiles only with
164+// gaps, never fullscreen windows.
165+func windowRadius(p Placement, gap, radius int) int {
166+ if !p.Fullscreen && (p.Floating || gap > 0) {
167+ return radius
168+ }
169+ return 0
170+}
M internal/core/separators_test.go +2 -2
@@ -201,12 +201,12 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
201201 }
202202 }
203203
204-// With gaps each tile has its own line: the focused one lights it whole.
204+// With gaps each tile owns four full border sides, including outer edges.
205205 func TestSeparatorsGaps(t *testing.T) {
206206 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}
207207 setVisibleNeighbors(ps, 4, bigOutput)
208208 seps := separators(ps, 2, 4, whole(bigOutput), true)
209- for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {
209+ for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {0, 8, "lit"}, {8, 0, "lit"}, {8, 15, "lit"}, {20, 8, "gray"}, {35, 8, "gray"}, {28, 0, "gray"}, {28, 15, "gray"}, {17, 8, ""}} {
210210 if got := colorAt(seps, c.x, c.y); got != c.want {
211211 t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)
212212 }
@@ -201,12 +201,12 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
201 }201 }
202 }202 }
203 203
204-// With gaps each tile has its own line: the focused one lights it whole.204+// With gaps each tile owns four full border sides, including outer edges.
205 func TestSeparatorsGaps(t *testing.T) {205 func TestSeparatorsGaps(t *testing.T) {
206 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}206 ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}}
207 setVisibleNeighbors(ps, 4, bigOutput)207 setVisibleNeighbors(ps, 4, bigOutput)
208 seps := separators(ps, 2, 4, whole(bigOutput), true)208 seps := separators(ps, 2, 4, whole(bigOutput), true)
209- for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} {209+ for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {0, 8, "lit"}, {8, 0, "lit"}, {8, 15, "lit"}, {20, 8, "gray"}, {35, 8, "gray"}, {28, 0, "gray"}, {28, 15, "gray"}, {17, 8, ""}} {
210 if got := colorAt(seps, c.x, c.y); got != c.want {210 if got := colorAt(seps, c.x, c.y); got != c.want {
211 t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)211 t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want)
212 }212 }
M internal/ports/config.go +4 -1
@@ -121,8 +121,11 @@ type Config struct {
121121 Capture bool
122122 SpawnTerminal bool
123123 }
124- // Border is drawn inside the window edge; Width 0 disables it.
124+ // Border is drawn inside the window edge; Width 0 disables it. Radius is
125+ // in logical pixels, >= 0 (at most 64 from the file): it rounds floats
126+ // always and tiles when gaps > 0; the renderer uses SceneWindow.Radius.
125127 Border struct {
128+ Radius int
126129 Width int
127130 Active string
128131 Inactive string
@@ -121,8 +121,11 @@ type Config struct {
121 Capture bool121 Capture bool
122 SpawnTerminal bool122 SpawnTerminal bool
123 }123 }
124- // Border is drawn inside the window edge; Width 0 disables it.124+ // Border is drawn inside the window edge; Width 0 disables it. Radius is
125+ // in logical pixels, >= 0 (at most 64 from the file): it rounds floats
126+ // always and tiles when gaps > 0; the renderer uses SceneWindow.Radius.
125 Border struct {127 Border struct {
128+ Radius int
126 Width int129 Width int
127 Active string130 Active string
128 Inactive string131 Inactive string
M internal/ports/scene.go +9 -2
@@ -168,15 +168,19 @@ func (s Scene) TileArea() (r Rect, ok bool) {
168168 }
169169
170170 // Border carries the window border style; colors are #rrggbb, "" skips
171-// drawing. Width is in logical pixels.
171+// drawing. Width and Radius are in logical pixels.
172172 type Border struct {
173+ // Radius is the corner radius in logical pixels, >= 0. It rounds
174+ // floats always and tiles when gaps > 0; the renderer uses
175+ // SceneWindow.Radius, which core sets per window.
176+ Radius int
173177 Width int
174178 Active, Inactive string
175179 }
176180
177181 // Separator is a line between windows, in logical pixels. Active lines
178182 // mark the focused window and use Border.Active, others Border.Inactive.
179-// Window is the float whose border it is; 0 for lines between tiles.
183+// Window owns the border (floats and tiles with gaps); 0 for shared tile lines.
180184 type Separator struct {
181185 Rect Rect
182186 Active bool
@@ -203,6 +207,9 @@ type FocusFlash struct {
203207
204208 // SceneWindow carries core → renderer window placement.
205209 type SceneWindow struct {
210+ // Radius rounds the window corners in logical pixels; zero is square.
211+ // Core sets Border.Radius here for floats and tiles with gaps.
212+ Radius int
206213 ID WindowID
207214 Rect Rect
208215 Focused, Fullscreen, Hidden bool
@@ -168,15 +168,19 @@ func (s Scene) TileArea() (r Rect, ok bool) {
168 }168 }
169 169
170 // Border carries the window border style; colors are #rrggbb, "" skips170 // Border carries the window border style; colors are #rrggbb, "" skips
171-// drawing. Width is in logical pixels.171+// drawing. Width and Radius are in logical pixels.
172 type Border struct {172 type Border struct {
173+ // Radius is the corner radius in logical pixels, >= 0. It rounds
174+ // floats always and tiles when gaps > 0; the renderer uses
175+ // SceneWindow.Radius, which core sets per window.
176+ Radius int
173 Width int177 Width int
174 Active, Inactive string178 Active, Inactive string
175 }179 }
176 180
177 // Separator is a line between windows, in logical pixels. Active lines181 // Separator is a line between windows, in logical pixels. Active lines
178 // mark the focused window and use Border.Active, others Border.Inactive.182 // mark the focused window and use Border.Active, others Border.Inactive.
179-// Window is the float whose border it is; 0 for lines between tiles.183+// Window owns the border (floats and tiles with gaps); 0 for shared tile lines.
180 type Separator struct {184 type Separator struct {
181 Rect Rect185 Rect Rect
182 Active bool186 Active bool
@@ -203,6 +207,9 @@ type FocusFlash struct {
203 207
204 // SceneWindow carries core → renderer window placement.208 // SceneWindow carries core → renderer window placement.
205 type SceneWindow struct {209 type SceneWindow struct {
210+ // Radius rounds the window corners in logical pixels; zero is square.
211+ // Core sets Border.Radius here for floats and tiles with gaps.
212+ Radius int
206 ID WindowID213 ID WindowID
207 Rect Rect214 Rect Rect
208 Focused, Fullscreen, Hidden bool215 Focused, Fullscreen, Hidden bool

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close