detached 5facfaf

feat(render): draw tmux-style focus separators between tiles

commit
5facfaf4e2ffa77c14e861b6fab3d5b7335289db
author
brice · · 2026-09-26 14:43 UTC
committer
brice · · 2026-09-26 15:07 UTC
parent
23657a0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +181 -64
M examples/config +7 -5
@@ -22,14 +22,16 @@ terminal = foot
2222
2323 background = #111111
2424
25-# Drawn inside the window edge; width 0 disables it, empty inactive draws nothing.
26-border.width = 2
27-border.active = #b4befe
28-border.inactive =
25+# Separator lines between tiled windows (tmux style), in logical pixels.
26+# The lines along the focused window use active; width 0 disables them,
27+# empty inactive draws only the focused lines. Floats get a full border.
28+border.width = 1
29+border.active = #6c6c6c
30+border.inactive = #2a2a2a
2931
3032 layout.gaps = 0
3133 # Columns sharing the screen equally before new ones scroll off to the right.
32-# A lone column fills the screen without a border.
34+# A lone column fills the screen without separators.
3335 layout.max-columns = 2
3436 # Past max-columns: scroll opens columns to the right; fixed keeps everything
3537 # on screen, each new window splitting the newest one (spiral).
@@ -22,14 +22,16 @@ terminal = foot
22 22
23 background = #11111123 background = #111111
24 24
25-# Drawn inside the window edge; width 0 disables it, empty inactive draws nothing.25+# Separator lines between tiled windows (tmux style), in logical pixels.
26-border.width = 226+# The lines along the focused window use active; width 0 disables them,
27-border.active = #b4befe27+# empty inactive draws only the focused lines. Floats get a full border.
28-border.inactive =28+border.width = 1
29+border.active = #6c6c6c
30+border.inactive = #2a2a2a
29 31
30 layout.gaps = 032 layout.gaps = 0
31 # Columns sharing the screen equally before new ones scroll off to the right.33 # Columns sharing the screen equally before new ones scroll off to the right.
32-# A lone column fills the screen without a border.34+# A lone column fills the screen without separators.
33 layout.max-columns = 235 layout.max-columns = 2
34 # Past max-columns: scroll opens columns to the right; fixed keeps everything36 # Past max-columns: scroll opens columns to the right; fixed keeps everything
35 # on screen, each new window splitting the newest one (spiral).37 # on screen, each new window splitting the newest one (spiral).
M internal/adapters/config/config.go +3 -2
@@ -82,8 +82,9 @@ func Defaults() ports.Config {
8282 c.Terminal.Command = []string{"foot"}
8383 c.Xwayland = "xwayland-satellite"
8484 c.Background.Color = "#111111"
85- c.Border.Width = 2
86- c.Border.Active = "#b4befe"
85+ c.Border.Width = 1
86+ c.Border.Active = "#6c6c6c"
87+ c.Border.Inactive = "#2a2a2a"
8788 c.Layout.MaxColumns = 2
8889 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
8990 c.Layout.Overflow = "scroll"
@@ -82,8 +82,9 @@ func Defaults() ports.Config {
82 c.Terminal.Command = []string{"foot"}82 c.Terminal.Command = []string{"foot"}
83 c.Xwayland = "xwayland-satellite"83 c.Xwayland = "xwayland-satellite"
84 c.Background.Color = "#111111"84 c.Background.Color = "#111111"
85- c.Border.Width = 285+ c.Border.Width = 1
86- c.Border.Active = "#b4befe"86+ c.Border.Active = "#6c6c6c"
87+ c.Border.Inactive = "#2a2a2a"
87 c.Layout.MaxColumns = 288 c.Layout.MaxColumns = 2
88 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}89 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
89 c.Layout.Overflow = "scroll"90 c.Layout.Overflow = "scroll"
M internal/adapters/drm/overlay.go +1 -1
@@ -91,7 +91,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
9191 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
9292 }
9393 }
94- if !pick.Fullscreen && !pick.Borderless && s.Border.Width > 0 {
94+ if !pick.Fullscreen && pick.Inset|pick.Neighbors != 0 && s.Border.Width > 0 {
9595 // The overlay shows the buffer alone; its border would go with it.
9696 return ports.SceneWindow{}, ports.SurfaceContent{}, "border"
9797 }
@@ -91,7 +91,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
91 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"91 return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
92 }92 }
93 }93 }
94- if !pick.Fullscreen && !pick.Borderless && s.Border.Width > 0 {94+ if !pick.Fullscreen && pick.Inset|pick.Neighbors != 0 && s.Border.Width > 0 {
95 // The overlay shows the buffer alone; its border would go with it.95 // The overlay shows the buffer alone; its border would go with it.
96 return ports.SceneWindow{}, ports.SurfaceContent{}, "border"96 return ports.SceneWindow{}, ports.SurfaceContent{}, "border"
97 }97 }
M internal/adapters/drm/overlay_test.go +4 -0
@@ -120,6 +120,10 @@ func TestOverlayPopupAndBorder(t *testing.T) {
120120 }
121121 s, _ = overlayScene()
122122 s.Border.Width = 2
123+ if _, _, reason := overlayCandidate(s, c); reason != "" {
124+ t.Fatalf("lone window: %q", reason)
125+ }
126+ s.Windows[len(s.Windows)-1].Neighbors = ports.SideLeft
123127 if _, _, reason := overlayCandidate(s, c); reason != "border" {
124128 t.Fatalf("border: %q", reason)
125129 }
@@ -120,6 +120,10 @@ func TestOverlayPopupAndBorder(t *testing.T) {
120 }120 }
121 s, _ = overlayScene()121 s, _ = overlayScene()
122 s.Border.Width = 2122 s.Border.Width = 2
123+ if _, _, reason := overlayCandidate(s, c); reason != "" {
124+ t.Fatalf("lone window: %q", reason)
125+ }
126+ s.Windows[len(s.Windows)-1].Neighbors = ports.SideLeft
123 if _, _, reason := overlayCandidate(s, c); reason != "border" {127 if _, _, reason := overlayCandidate(s, c); reason != "border" {
124 t.Fatalf("border: %q", reason)128 t.Fatalf("border: %q", reason)
125 }129 }
M internal/adapters/vulkan/compose_test.go +4 -4
@@ -30,7 +30,7 @@ func TestRendererScaledSHMFiltersLinearly(t *testing.T) {
3030 px := []byte{0, 0, 0, 255, 255, 255, 255, 255}
3131 c := shmContent(t, 2, 1, 8, px)
3232 c.LogicalW, c.LogicalH = 64, 16
33- scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, Borderless: true}}}
33+ scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
3434 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
3535 t.Fatal(err)
3636 }
@@ -62,7 +62,7 @@ func TestRendererScaledDMABufFiltersLinearly(t *testing.T) {
6262 return [4]byte{255, 255, 255, 255}
6363 })
6464 buf := &ports.DMABuf{ID: 3, Width: 64, Height: 1, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
65- scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 128, H: 4}, Borderless: true}}}
65+ scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 128, H: 4}}}}
6666 c := ports.SurfaceContent{ID: 1, Width: 64, Height: 1, LogicalW: 128, LogicalH: 4, DMABuf: buf}
6767 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
6868 t.Fatal(err)
@@ -108,7 +108,7 @@ func TestRendererSHMCopiedOncePerContent(t *testing.T) {
108108 defer r.Close()
109109 c := solidContent(t, 16, 16, color.RGBA{1, 2, 3, 255})
110110 c.ID, c.Seq = 1, 1
111- scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}, Borderless: true}}}
111+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}}
112112 contents := map[ports.WindowID]ports.SurfaceContent{1: c}
113113 render := func() {
114114 t.Helper()
@@ -160,7 +160,7 @@ func TestRendererSHMDoubleBufferWaitsForReaders(t *testing.T) {
160160 if !r.syncFD {
161161 t.Skip("frames are finished on return without sync files")
162162 }
163- scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}, Borderless: true}}}
163+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}}
164164 var readers []uint64
165165 for seq := uint64(1); seq <= 3; seq++ {
166166 c := solidContent(t, 16, 16, color.RGBA{uint8(seq * 60), 0, 0, 255})
@@ -30,7 +30,7 @@ func TestRendererScaledSHMFiltersLinearly(t *testing.T) {
30 px := []byte{0, 0, 0, 255, 255, 255, 255, 255}30 px := []byte{0, 0, 0, 255, 255, 255, 255, 255}
31 c := shmContent(t, 2, 1, 8, px)31 c := shmContent(t, 2, 1, 8, px)
32 c.LogicalW, c.LogicalH = 64, 1632 c.LogicalW, c.LogicalH = 64, 16
33- scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}, Borderless: true}}}33+ scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
34 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {34 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
35 t.Fatal(err)35 t.Fatal(err)
36 }36 }
@@ -62,7 +62,7 @@ func TestRendererScaledDMABufFiltersLinearly(t *testing.T) {
62 return [4]byte{255, 255, 255, 255}62 return [4]byte{255, 255, 255, 255}
63 })63 })
64 buf := &ports.DMABuf{ID: 3, Width: 64, Height: 1, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}64 buf := &ports.DMABuf{ID: 3, Width: 64, Height: 1, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
65- scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 128, H: 4}, Borderless: true}}}65+ scene := ports.Scene{Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 128, H: 4}}}}
66 c := ports.SurfaceContent{ID: 1, Width: 64, Height: 1, LogicalW: 128, LogicalH: 4, DMABuf: buf}66 c := ports.SurfaceContent{ID: 1, Width: 64, Height: 1, LogicalW: 128, LogicalH: 4, DMABuf: buf}
67 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {67 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
68 t.Fatal(err)68 t.Fatal(err)
@@ -108,7 +108,7 @@ func TestRendererSHMCopiedOncePerContent(t *testing.T) {
108 defer r.Close()108 defer r.Close()
109 c := solidContent(t, 16, 16, color.RGBA{1, 2, 3, 255})109 c := solidContent(t, 16, 16, color.RGBA{1, 2, 3, 255})
110 c.ID, c.Seq = 1, 1110 c.ID, c.Seq = 1, 1
111- scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}, Borderless: true}}}111+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}}
112 contents := map[ports.WindowID]ports.SurfaceContent{1: c}112 contents := map[ports.WindowID]ports.SurfaceContent{1: c}
113 render := func() {113 render := func() {
114 t.Helper()114 t.Helper()
@@ -160,7 +160,7 @@ func TestRendererSHMDoubleBufferWaitsForReaders(t *testing.T) {
160 if !r.syncFD {160 if !r.syncFD {
161 t.Skip("frames are finished on return without sync files")161 t.Skip("frames are finished on return without sync files")
162 }162 }
163- scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}, Borderless: true}}}163+ scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 16, H: 16}}}}
164 var readers []uint64164 var readers []uint64
165 for seq := uint64(1); seq <= 3; seq++ {165 for seq := uint64(1); seq <= 3; seq++ {
166 c := solidContent(t, 16, 16, color.RGBA{uint8(seq * 60), 0, 0, 255})166 c := solidContent(t, 16, 16, color.RGBA{uint8(seq * 60), 0, 0, 255})
M internal/adapters/vulkan/damage_test.go +2 -2
@@ -54,7 +54,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) {
5454 base := shmContent(t, w, h, w*4, pixels)
5555 base.ID = 1
5656 f := base.SHM.File
57- scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}, Borderless: true}}}
57+ scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}}
5858 hist := []ports.SeqDamage{{Seq: 1, Full: true}}
5959 c := damagedContent(t, f, *base, 1, pixels, hist)
6060 step := func(i int, c ports.SurfaceContent) {
@@ -113,7 +113,7 @@ func TestRendererDamageWindowLeftOutAndBack(t *testing.T) {
113113 c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255})
114114 c.ID, c.Seq = 1, 1
115115 contents := map[ports.WindowID]ports.SurfaceContent{1: c}
116- with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}, Borderless: true}}}
116+ with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}}}}
117117 without := with
118118 without.Windows = nil
119119 check := func(s ports.Scene, what string) {
@@ -54,7 +54,7 @@ func TestRendererDamageMatchesFullRedraw(t *testing.T) {
54 base := shmContent(t, w, h, w*4, pixels)54 base := shmContent(t, w, h, w*4, pixels)
55 base.ID = 155 base.ID = 1
56 f := base.SHM.File56 f := base.SHM.File
57- scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}, Borderless: true}}}57+ scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}}
58 hist := []ports.SeqDamage{{Seq: 1, Full: true}}58 hist := []ports.SeqDamage{{Seq: 1, Full: true}}
59 c := damagedContent(t, f, *base, 1, pixels, hist)59 c := damagedContent(t, f, *base, 1, pixels, hist)
60 step := func(i int, c ports.SurfaceContent) {60 step := func(i int, c ports.SurfaceContent) {
@@ -113,7 +113,7 @@ func TestRendererDamageWindowLeftOutAndBack(t *testing.T) {
113 c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255})113 c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255})
114 c.ID, c.Seq = 1, 1114 c.ID, c.Seq = 1, 1
115 contents := map[ports.WindowID]ports.SurfaceContent{1: c}115 contents := map[ports.WindowID]ports.SurfaceContent{1: c}
116- with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}, Borderless: true}}}116+ with := ports.Scene{Seq: 9, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 16, H: 16}}}}
117 without := with117 without := with
118 without.Windows = nil118 without.Windows = nil
119 check := func(s ports.Scene, what string) {119 check := func(s ports.Scene, what string) {
M internal/adapters/vulkan/renderer.go +46 -18
@@ -422,6 +422,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
422422 }
423423 }
424424 addLayers(false)
425+ var focused *ports.SceneWindow
425426 for _, w := range s.Windows {
426427 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
427428 continue
@@ -431,11 +432,12 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
431432 continue
432433 }
433434 // Content sits inside the border; core sized the client to match.
434- b := 0
435- if !w.Fullscreen && !w.Borderless {
436- b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2)
435+ b, inset := 0, ports.Sides(0)
436+ if !w.Fullscreen {
437+ b, inset = s.Border.Width, w.Inset
437438 }
438- cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b
439+ c := w.Rect.Inset(inset, b)
440+ cx, cy, cw, ch := c.X, c.Y, c.W, c.H
439441 body := physRect(cx, cy, cw, ch)
440442 content := contents[w.ID]
441443 dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H))
@@ -445,22 +447,25 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
445447 add(body, parseColor(s.Background))
446448 place(w.ID, &content, cx, cy, cw, ch)
447449 }
448- borderColor := s.Border.Inactive
449- if w.Focused {
450- borderColor = s.Border.Active
451- }
452- if b > 0 && borderColor != "" {
453- rgb := parseColor(borderColor)
454- outer, inner := physRect(x, y, w.Rect.W, w.Rect.H), physRect(cx, cy, cw, ch)
455- for _, strip := range []image.Rectangle{
456- image.Rect(outer.Min.X, outer.Min.Y, outer.Max.X, inner.Min.Y),
457- image.Rect(outer.Min.X, inner.Max.Y, outer.Max.X, outer.Max.Y),
458- image.Rect(outer.Min.X, inner.Min.Y, inner.Min.X, inner.Max.Y),
459- image.Rect(inner.Max.X, inner.Min.Y, outer.Max.X, inner.Max.Y),
460- } {
461- add(strip, rgb)
450+ if s.Border.Inactive != "" {
451+ for _, strip := range borderStrips(w.Rect, inset, 0, b) {
452+ add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Inactive))
462453 }
463454 }
455+ if w.Focused && !w.Fullscreen {
456+ focused = &w
457+ }
458+ }
459+ // The focused window lights the lines along its own sides only: its
460+ // inset border, and its neighbors' border line where they own it.
461+ if focused != nil && s.Border.Active != "" && s.Border.Width > 0 {
462+ f := focused
463+ for _, strip := range borderStrips(f.Rect, f.Inset, 0, s.Border.Width) {
464+ add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active))
465+ }
466+ for _, strip := range borderStrips(f.Rect, f.Neighbors&^f.Inset, s.Border.Width, s.Border.Width) {
467+ add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active))
468+ }
464469 }
465470 // Popups draw only what the client drew, shadows clipped. Window popups
466471 // stay with the windows; a layer's go over every layer.
@@ -593,3 +598,26 @@ func (r *Renderer) Close() {
593598 r.instance = 0
594599 }
595600 }
601+
602+// borderStrips are the b-wide strips along sides of r, in logical pixels:
603+// inside r when out is 0, else just outside it (a neighbor's border).
604+func borderStrips(r ports.Rect, sides ports.Sides, out, b int) []ports.Rect {
605+ if b <= 0 {
606+ return nil
607+ }
608+ b = min(b, max(r.W/2, out), max(r.H/2, out))
609+ var strips []ports.Rect
610+ if sides&ports.SideLeft != 0 {
611+ strips = append(strips, ports.Rect{X: r.X - out, Y: r.Y, W: b, H: r.H})
612+ }
613+ if sides&ports.SideRight != 0 {
614+ strips = append(strips, ports.Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H})
615+ }
616+ if sides&ports.SideTop != 0 {
617+ strips = append(strips, ports.Rect{X: r.X, Y: r.Y - out, W: r.W, H: b})
618+ }
619+ if sides&ports.SideBottom != 0 {
620+ strips = append(strips, ports.Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b})
621+ }
622+ return strips
623+}
@@ -422,6 +422,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
422 }422 }
423 }423 }
424 addLayers(false)424 addLayers(false)
425+ var focused *ports.SceneWindow
425 for _, w := range s.Windows {426 for _, w := range s.Windows {
426 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {427 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
427 continue428 continue
@@ -431,11 +432,12 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
431 continue432 continue
432 }433 }
433 // Content sits inside the border; core sized the client to match.434 // Content sits inside the border; core sized the client to match.
434- b := 0435+ b, inset := 0, ports.Sides(0)
435- if !w.Fullscreen && !w.Borderless {436+ if !w.Fullscreen {
436- b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2)437+ b, inset = s.Border.Width, w.Inset
437 }438 }
438- cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b439+ c := w.Rect.Inset(inset, b)
440+ cx, cy, cw, ch := c.X, c.Y, c.W, c.H
439 body := physRect(cx, cy, cw, ch)441 body := physRect(cx, cy, cw, ch)
440 content := contents[w.ID]442 content := contents[w.ID]
441 dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H))443 dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H))
@@ -445,22 +447,25 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
445 add(body, parseColor(s.Background))447 add(body, parseColor(s.Background))
446 place(w.ID, &content, cx, cy, cw, ch)448 place(w.ID, &content, cx, cy, cw, ch)
447 }449 }
448- borderColor := s.Border.Inactive450+ if s.Border.Inactive != "" {
449- if w.Focused {451+ for _, strip := range borderStrips(w.Rect, inset, 0, b) {
450- borderColor = s.Border.Active452+ add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Inactive))
451- }
452- if b > 0 && borderColor != "" {
453- rgb := parseColor(borderColor)
454- outer, inner := physRect(x, y, w.Rect.W, w.Rect.H), physRect(cx, cy, cw, ch)
455- for _, strip := range []image.Rectangle{
456- image.Rect(outer.Min.X, outer.Min.Y, outer.Max.X, inner.Min.Y),
457- image.Rect(outer.Min.X, inner.Max.Y, outer.Max.X, outer.Max.Y),
458- image.Rect(outer.Min.X, inner.Min.Y, inner.Min.X, inner.Max.Y),
459- image.Rect(inner.Max.X, inner.Min.Y, outer.Max.X, inner.Max.Y),
460- } {
461- add(strip, rgb)
462 }453 }
463 }454 }
455+ if w.Focused && !w.Fullscreen {
456+ focused = &w
457+ }
458+ }
459+ // The focused window lights the lines along its own sides only: its
460+ // inset border, and its neighbors' border line where they own it.
461+ if focused != nil && s.Border.Active != "" && s.Border.Width > 0 {
462+ f := focused
463+ for _, strip := range borderStrips(f.Rect, f.Inset, 0, s.Border.Width) {
464+ add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active))
465+ }
466+ for _, strip := range borderStrips(f.Rect, f.Neighbors&^f.Inset, s.Border.Width, s.Border.Width) {
467+ add(physRect(strip.X, strip.Y, strip.W, strip.H), parseColor(s.Border.Active))
468+ }
464 }469 }
465 // Popups draw only what the client drew, shadows clipped. Window popups470 // Popups draw only what the client drew, shadows clipped. Window popups
466 // stay with the windows; a layer's go over every layer.471 // stay with the windows; a layer's go over every layer.
@@ -593,3 +598,26 @@ func (r *Renderer) Close() {
593 r.instance = 0598 r.instance = 0
594 }599 }
595 }600 }
601+
602+// borderStrips are the b-wide strips along sides of r, in logical pixels:
603+// inside r when out is 0, else just outside it (a neighbor's border).
604+func borderStrips(r ports.Rect, sides ports.Sides, out, b int) []ports.Rect {
605+ if b <= 0 {
606+ return nil
607+ }
608+ b = min(b, max(r.W/2, out), max(r.H/2, out))
609+ var strips []ports.Rect
610+ if sides&ports.SideLeft != 0 {
611+ strips = append(strips, ports.Rect{X: r.X - out, Y: r.Y, W: b, H: r.H})
612+ }
613+ if sides&ports.SideRight != 0 {
614+ strips = append(strips, ports.Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H})
615+ }
616+ if sides&ports.SideTop != 0 {
617+ strips = append(strips, ports.Rect{X: r.X, Y: r.Y - out, W: r.W, H: b})
618+ }
619+ if sides&ports.SideBottom != 0 {
620+ strips = append(strips, ports.Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b})
621+ }
622+ return strips
623+}
M internal/adapters/vulkan/renderer_test.go +18 -5
@@ -57,13 +57,26 @@ func TestRendererRender(t *testing.T) {
5757 }
5858 }
5959 }
60- win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}
60+ win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}, Inset: ports.SideAll}
6161 c := windowColor(1)
6262 wc := color.RGBA{c[0], c[1], c[2], 255}
6363 s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}
6464 check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 21}: {0x31, 0x32, 0x44, 255}, {21, 22}: {0x31, 0x32, 0x44, 255}, {23, 23}: {0x31, 0x32, 0x44, 255}, {24, 24}: bg})
6565 s.Windows[0].Focused = true
6666 check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc})
67+ // tmux style: the left window owns the shared line; focusing the right
68+ // window lights that line too.
69+ gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255}
70+ pair := s
71+ pair.Windows = []ports.SceneWindow{
72+ {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideRight},
73+ {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Neighbors: ports.SideLeft, Focused: true},
74+ }
75+ check(pair, map[image.Point]color.RGBA{{14, 5}: lit, {15, 5}: lit, {13, 5}: wc, {0, 0}: wc})
76+ pair.Windows[1].Focused, pair.Windows[0].Focused = false, true
77+ check(pair, map[image.Point]color.RGBA{{14, 5}: lit})
78+ pair.Windows[0].Focused = false
79+ check(pair, map[image.Point]color.RGBA{{14, 5}: gray, {15, 5}: gray})
6780 s.Windows[0].Fullscreen = true
6881 check(s, map[image.Point]color.RGBA{{9, 9}: wc})
6982 s.Windows[0].Fullscreen = false
@@ -135,7 +148,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
135148 t.Errorf("overlap = %v, want %v", got, want)
136149 }
137150 scene.Border = ports.Border{Width: 4, Active: "#ffffff"}
138- scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}
151+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}}
139152 px := make([]byte, 16*16*4)
140153 for i := 0; i < len(px); i += 4 {
141154 copy(px[i:i+4], []byte{3, 5, 7, 0})
@@ -214,7 +227,7 @@ func TestRendererScale(t *testing.T) {
214227 red := [3]uint8{200, 0, 0}
215228 // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28);
216229 // its 20x20 buffer (fractional client) is copied 1:1.
217- s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}, Borderless: true}}}
230+ s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}}}}
218231 scaled := shmContent(t, 20, 20, 80, solid(20, 20, red))
219232 scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10
220233 content := map[ports.WindowID]ports.SurfaceContent{1: *scaled}
@@ -299,7 +312,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) {
299312 defer r.Close()
300313 red, green, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 0, 255, 255}
301314 bg := color.RGBA{16, 32, 48, 255}
302- scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}, Borderless: true}}}
315+ scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}}}}
303316 // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow.
304317 root := solidContent(t, 30, 20, red)
305318 root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10}
@@ -396,7 +409,7 @@ func TestRendererAlphaBlend(t *testing.T) {
396409 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)),
397410 }
398411 scene := ports.Scene{Background: "#000000",
399- Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}, Borderless: true}},
412+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}}},
400413 Layers: []ports.SceneLayer{
401414 {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}},
402415 {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}},
@@ -57,13 +57,26 @@ func TestRendererRender(t *testing.T) {
57 }57 }
58 }58 }
59 }59 }
60- win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}60+ win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}, Inset: ports.SideAll}
61 c := windowColor(1)61 c := windowColor(1)
62 wc := color.RGBA{c[0], c[1], c[2], 255}62 wc := color.RGBA{c[0], c[1], c[2], 255}
63 s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}63 s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}}
64 check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 21}: {0x31, 0x32, 0x44, 255}, {21, 22}: {0x31, 0x32, 0x44, 255}, {23, 23}: {0x31, 0x32, 0x44, 255}, {24, 24}: bg})64 check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: {0x31, 0x32, 0x44, 255}, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 21}: {0x31, 0x32, 0x44, 255}, {21, 22}: {0x31, 0x32, 0x44, 255}, {23, 23}: {0x31, 0x32, 0x44, 255}, {24, 24}: bg})
65 s.Windows[0].Focused = true65 s.Windows[0].Focused = true
66 check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc})66 check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc})
67+ // tmux style: the left window owns the shared line; focusing the right
68+ // window lights that line too.
69+ gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255}
70+ pair := s
71+ pair.Windows = []ports.SceneWindow{
72+ {ID: 1, Rect: ports.Rect{X: 0, Y: 0, W: 16, H: 16}, Inset: ports.SideRight, Neighbors: ports.SideRight},
73+ {ID: 2, Rect: ports.Rect{X: 16, Y: 0, W: 16, H: 16}, Neighbors: ports.SideLeft, Focused: true},
74+ }
75+ check(pair, map[image.Point]color.RGBA{{14, 5}: lit, {15, 5}: lit, {13, 5}: wc, {0, 0}: wc})
76+ pair.Windows[1].Focused, pair.Windows[0].Focused = false, true
77+ check(pair, map[image.Point]color.RGBA{{14, 5}: lit})
78+ pair.Windows[0].Focused = false
79+ check(pair, map[image.Point]color.RGBA{{14, 5}: gray, {15, 5}: gray})
67 s.Windows[0].Fullscreen = true80 s.Windows[0].Fullscreen = true
68 check(s, map[image.Point]color.RGBA{{9, 9}: wc})81 check(s, map[image.Point]color.RGBA{{9, 9}: wc})
69 s.Windows[0].Fullscreen = false82 s.Windows[0].Fullscreen = false
@@ -135,7 +148,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
135 t.Errorf("overlap = %v, want %v", got, want)148 t.Errorf("overlap = %v, want %v", got, want)
136 }149 }
137 scene.Border = ports.Border{Width: 4, Active: "#ffffff"}150 scene.Border = ports.Border{Width: 4, Active: "#ffffff"}
138- scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true}}151+ scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}}
139 px := make([]byte, 16*16*4)152 px := make([]byte, 16*16*4)
140 for i := 0; i < len(px); i += 4 {153 for i := 0; i < len(px); i += 4 {
141 copy(px[i:i+4], []byte{3, 5, 7, 0})154 copy(px[i:i+4], []byte{3, 5, 7, 0})
@@ -214,7 +227,7 @@ func TestRendererScale(t *testing.T) {
214 red := [3]uint8{200, 0, 0}227 red := [3]uint8{200, 0, 0}
215 // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28);228 // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28);
216 // its 20x20 buffer (fractional client) is copied 1:1.229 // its 20x20 buffer (fractional client) is copied 1:1.
217- s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}, Borderless: true}}}230+ s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}}}}
218 scaled := shmContent(t, 20, 20, 80, solid(20, 20, red))231 scaled := shmContent(t, 20, 20, 80, solid(20, 20, red))
219 scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10232 scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10
220 content := map[ports.WindowID]ports.SurfaceContent{1: *scaled}233 content := map[ports.WindowID]ports.SurfaceContent{1: *scaled}
@@ -299,7 +312,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) {
299 defer r.Close()312 defer r.Close()
300 red, green, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 0, 255, 255}313 red, green, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 0, 255, 255}
301 bg := color.RGBA{16, 32, 48, 255}314 bg := color.RGBA{16, 32, 48, 255}
302- scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}, Borderless: true}}}315+ scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}}}}
303 // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow.316 // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow.
304 root := solidContent(t, 30, 20, red)317 root := solidContent(t, 30, 20, red)
305 root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10}318 root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10}
@@ -396,7 +409,7 @@ func TestRendererAlphaBlend(t *testing.T) {
396 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)),409 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)),
397 }410 }
398 scene := ports.Scene{Background: "#000000",411 scene := ports.Scene{Background: "#000000",
399- Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}, Borderless: true}},412+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}}},
400 Layers: []ports.SceneLayer{413 Layers: []ports.SceneLayer{
401 {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}},414 {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}},
402 {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}},415 {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}},
M internal/core/core.go +5 -7
@@ -292,15 +292,13 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
292292 }
293293 }
294294
295-// clientRect is the placement minus the border drawn around the client.
295+// clientRect is the placement minus the border on its inset sides.
296296 // The renderer applies the same inset (ports.Border).
297297 func (c *Core) clientRect(p Placement) Rect {
298- r := p.Rect
299- if p.Fullscreen || p.Borderless {
300- return r
298+ if p.Fullscreen {
299+ return p.Rect
301300 }
302- b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)
303- return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
301+ return p.Rect.Inset(p.Inset, c.cfg.Border.Width)
304302 }
305303
306304 // visible reports whether the window, layer surface or popup is on screen
@@ -405,7 +403,7 @@ func (c *Core) publish(ctx context.Context) error {
405403 alive[p.ID] = true
406404 // Only the focused output shows the focused border.
407405 focused := p.Focused && i == c.focusScreen
408- scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless})
406+ scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Neighbors: p.Neighbors, Inset: p.Inset})
409407 floating := p.Floating
410408 if p.Hidden {
411409 if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {
@@ -292,15 +292,13 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
292 }292 }
293 }293 }
294 294
295-// clientRect is the placement minus the border drawn around the client.295+// clientRect is the placement minus the border on its inset sides.
296 // The renderer applies the same inset (ports.Border).296 // The renderer applies the same inset (ports.Border).
297 func (c *Core) clientRect(p Placement) Rect {297 func (c *Core) clientRect(p Placement) Rect {
298- r := p.Rect298+ if p.Fullscreen {
299- if p.Fullscreen || p.Borderless {299+ return p.Rect
300- return r
301 }300 }
302- b := min(max(c.cfg.Border.Width, 0), r.W/2, r.H/2)301+ return p.Rect.Inset(p.Inset, c.cfg.Border.Width)
303- return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
304 }302 }
305 303
306 // visible reports whether the window, layer surface or popup is on screen304 // visible reports whether the window, layer surface or popup is on screen
@@ -405,7 +403,7 @@ func (c *Core) publish(ctx context.Context) error {
405 alive[p.ID] = true403 alive[p.ID] = true
406 // Only the focused output shows the focused border.404 // Only the focused output shows the focused border.
407 focused := p.Focused && i == c.focusScreen405 focused := p.Focused && i == c.focusScreen
408- scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless})406+ scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Neighbors: p.Neighbors, Inset: p.Inset})
409 floating := p.Floating407 floating := p.Floating
410 if p.Hidden {408 if p.Hidden {
411 if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {409 if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {
M internal/core/core_test.go +7 -7
@@ -69,7 +69,7 @@ func TestOwner(t *testing.T) {
6969 client <- ports.WindowMapped{ID: 1}
7070 s = scene(t, scenes)
7171 // A single column fills the usable width.
72- if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless {
72+ if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
7373 t.Fatal(s)
7474 }
7575 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) {
@@ -301,15 +301,15 @@ func TestBorderInset(t *testing.T) {
301301 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) {
302302 t.Fatalf("%v for outer %v", v, r)
303303 }
304- // With a second column, the scene keeps the outer rect and the client is
305- // configured 2px smaller on each side.
304+ // With a second column, the left window owns the shared separator: its
305+ // client is 2px narrower on the right; the right window keeps its rect.
306306 client <- ports.WindowMapped{ID: 2}
307307 s := scene(t, scenes)
308308 r = s.Windows[1].Rect
309- if s.Windows[1].ID != 2 || s.Windows[1].Borderless {
309+ if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || s.Windows[1].Neighbors != ports.SideLeft {
310310 t.Fatal(s)
311311 }
312- want := ports.ConfigureWindow{ID: 2, Width: r.W - 4, Height: r.H - 4, Activated: true, Output: "OUT-1"}
312+ want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"}
313313 found := false
314314 for len(commands) > 0 {
315315 if v := <-commands; v == want {
@@ -320,7 +320,7 @@ func TestBorderInset(t *testing.T) {
320320 t.Fatalf("no %v for outer %v", want, r)
321321 }
322322 input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
323- if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) {
323+ if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 2, Y: 5}) {
324324 t.Fatal(v)
325325 }
326326 }
@@ -421,7 +421,7 @@ func TestWorkspaceSwitch(t *testing.T) {
421421 // A new window opens on workspace 2, alone and borderless.
422422 client <- ports.WindowMapped{ID: 2}
423423 s = scene(t, scenes)
424- if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless {
424+ if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {
425425 t.Fatal(s)
426426 }
427427 }
@@ -69,7 +69,7 @@ func TestOwner(t *testing.T) {
69 client <- ports.WindowMapped{ID: 1}69 client <- ports.WindowMapped{ID: 1}
70 s = scene(t, scenes)70 s = scene(t, scenes)
71 // A single column fills the usable width.71 // A single column fills the usable width.
72- if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless {72+ if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
73 t.Fatal(s)73 t.Fatal(s)
74 }74 }
75 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) {75 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) {
@@ -301,15 +301,15 @@ func TestBorderInset(t *testing.T) {
301 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) {301 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) {
302 t.Fatalf("%v for outer %v", v, r)302 t.Fatalf("%v for outer %v", v, r)
303 }303 }
304- // With a second column, the scene keeps the outer rect and the client is304+ // With a second column, the left window owns the shared separator: its
305- // configured 2px smaller on each side.305+ // client is 2px narrower on the right; the right window keeps its rect.
306 client <- ports.WindowMapped{ID: 2}306 client <- ports.WindowMapped{ID: 2}
307 s := scene(t, scenes)307 s := scene(t, scenes)
308 r = s.Windows[1].Rect308 r = s.Windows[1].Rect
309- if s.Windows[1].ID != 2 || s.Windows[1].Borderless {309+ if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || s.Windows[1].Neighbors != ports.SideLeft {
310 t.Fatal(s)310 t.Fatal(s)
311 }311 }
312- want := ports.ConfigureWindow{ID: 2, Width: r.W - 4, Height: r.H - 4, Activated: true, Output: "OUT-1"}312+ want := ports.ConfigureWindow{ID: 1, Width: s.Windows[0].Rect.W - 2, Height: r.H, Activated: false, Output: "OUT-1"}
313 found := false313 found := false
314 for len(commands) > 0 {314 for len(commands) > 0 {
315 if v := <-commands; v == want {315 if v := <-commands; v == want {
@@ -320,7 +320,7 @@ func TestBorderInset(t *testing.T) {
320 t.Fatalf("no %v for outer %v", want, r)320 t.Fatalf("no %v for outer %v", want, r)
321 }321 }
322 input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}322 input <- ports.PointerMotion{X: float64(r.X + 2), Y: float64(r.Y + 5), TimeMsec: 1}
323- if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 0, Y: 3}) {323+ if v := command(t, commands); v != (ports.PointerFocus{ID: 2, X: 2, Y: 5}) {
324 t.Fatal(v)324 t.Fatal(v)
325 }325 }
326 }326 }
@@ -421,7 +421,7 @@ func TestWorkspaceSwitch(t *testing.T) {
421 // A new window opens on workspace 2, alone and borderless.421 // A new window opens on workspace 2, alone and borderless.
422 client <- ports.WindowMapped{ID: 2}422 client <- ports.WindowMapped{ID: 2}
423 s = scene(t, scenes)423 s = scene(t, scenes)
424- if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless {424+ if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {
425 t.Fatal(s)425 t.Fatal(s)
426 }426 }
427 }427 }
M internal/core/layout.go +42 -5
@@ -93,9 +93,12 @@ type Placement struct {
9393 Fullscreen, Focused, Hidden bool
9494 // Floating windows sit over the columns at their own size.
9595 Floating bool
96- // Borderless is set when the column fills the usable width: it is the only
97- // column on screen, so no border marks focus.
98- Borderless bool
96+ // Neighbors are the sides touching another tiled window. Inset are the
97+ // sides where the border takes room from the window: all sides of a
98+ // float; for tiles, the right and bottom neighbors only, so two
99+ // windows share one separator line (tmux style). With gaps, each tile
100+ // insets all its neighbor sides.
101+ Neighbors, Inset ports.Sides
99102 }
100103
101104 // Overflow says what happens past MaxColumns columns.
@@ -665,13 +668,14 @@ func (w *Workspace) Layout() []Placement {
665668 r = Rect{}
666669 }
667670 focused := !floatFocused && i == w.Focus && j == c.Focus
668- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)})
671+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden})
669672 y += h + gap
670673 }
671674 }
675+ setNeighbors(result, gap, w.Output)
672676 // Floating windows go last: they are drawn and hit on top.
673677 for _, f := range w.Floats {
674- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID}
678+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll}
675679 // Floats stay above a fullscreen window: a dialog opened from a
676680 // fullscreen app must be seen.
677681 if w.fullscreen == f.ID {
@@ -682,6 +686,39 @@ func (w *Workspace) Layout() []Placement {
682686 return result
683687 }
684688
689+// setNeighbors marks the sides where tiles touch across the gap. Tiles
690+// scrolled off the output are no neighbors: no line at the screen edge.
691+func setNeighbors(tiles []Placement, gap int, out Rect) {
692+ overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
693+ for i := range tiles {
694+ a := &tiles[i]
695+ if a.Hidden || a.Fullscreen || a.Rect.W <= 0 || a.Rect.H <= 0 {
696+ continue
697+ }
698+ for j := range tiles {
699+ b := tiles[j].Rect
700+ if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 || b.X >= out.X+out.W || b.X+b.W <= out.X {
701+ continue
702+ }
703+ r := a.Rect
704+ switch {
705+ case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H):
706+ a.Neighbors |= ports.SideRight
707+ case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H):
708+ a.Neighbors |= ports.SideLeft
709+ case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W):
710+ a.Neighbors |= ports.SideBottom
711+ case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W):
712+ a.Neighbors |= ports.SideTop
713+ }
714+ }
715+ a.Inset = a.Neighbors
716+ if gap == 0 {
717+ a.Inset &= ports.SideRight | ports.SideBottom
718+ }
719+ }
720+}
721+
685722 // floatRect centres a floating window in the usable area, its border
686723 // around its client size, clamped to the area.
687724 func (w *Workspace) floatRect(f Float) Rect {
@@ -93,9 +93,12 @@ type Placement struct {
93 Fullscreen, Focused, Hidden bool93 Fullscreen, Focused, Hidden bool
94 // Floating windows sit over the columns at their own size.94 // Floating windows sit over the columns at their own size.
95 Floating bool95 Floating bool
96- // Borderless is set when the column fills the usable width: it is the only96+ // Neighbors are the sides touching another tiled window. Inset are the
97- // column on screen, so no border marks focus.97+ // sides where the border takes room from the window: all sides of a
98- Borderless bool98+ // float; for tiles, the right and bottom neighbors only, so two
99+ // windows share one separator line (tmux style). With gaps, each tile
100+ // insets all its neighbor sides.
101+ Neighbors, Inset ports.Sides
99 }102 }
100 103
101 // Overflow says what happens past MaxColumns columns.104 // Overflow says what happens past MaxColumns columns.
@@ -665,13 +668,14 @@ func (w *Workspace) Layout() []Placement {
665 r = Rect{}668 r = Rect{}
666 }669 }
667 focused := !floatFocused && i == w.Focus && j == c.Focus670 focused := !floatFocused && i == w.Focus && j == c.Focus
668- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)})671+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden})
669 y += h + gap672 y += h + gap
670 }673 }
671 }674 }
675+ setNeighbors(result, gap, w.Output)
672 // Floating windows go last: they are drawn and hit on top.676 // Floating windows go last: they are drawn and hit on top.
673 for _, f := range w.Floats {677 for _, f := range w.Floats {
674- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID}678+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll}
675 // Floats stay above a fullscreen window: a dialog opened from a679 // Floats stay above a fullscreen window: a dialog opened from a
676 // fullscreen app must be seen.680 // fullscreen app must be seen.
677 if w.fullscreen == f.ID {681 if w.fullscreen == f.ID {
@@ -682,6 +686,39 @@ func (w *Workspace) Layout() []Placement {
682 return result686 return result
683 }687 }
684 688
689+// setNeighbors marks the sides where tiles touch across the gap. Tiles
690+// scrolled off the output are no neighbors: no line at the screen edge.
691+func setNeighbors(tiles []Placement, gap int, out Rect) {
692+ overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
693+ for i := range tiles {
694+ a := &tiles[i]
695+ if a.Hidden || a.Fullscreen || a.Rect.W <= 0 || a.Rect.H <= 0 {
696+ continue
697+ }
698+ for j := range tiles {
699+ b := tiles[j].Rect
700+ if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 || b.X >= out.X+out.W || b.X+b.W <= out.X {
701+ continue
702+ }
703+ r := a.Rect
704+ switch {
705+ case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H):
706+ a.Neighbors |= ports.SideRight
707+ case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H):
708+ a.Neighbors |= ports.SideLeft
709+ case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W):
710+ a.Neighbors |= ports.SideBottom
711+ case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W):
712+ a.Neighbors |= ports.SideTop
713+ }
714+ }
715+ a.Inset = a.Neighbors
716+ if gap == 0 {
717+ a.Inset &= ports.SideRight | ports.SideBottom
718+ }
719+ }
720+}
721+
685 // floatRect centres a floating window in the usable area, its border722 // floatRect centres a floating window in the usable area, its border
686 // around its client size, clamped to the area.723 // around its client size, clamped to the area.
687 func (w *Workspace) floatRect(f Float) Rect {724 func (w *Workspace) floatRect(f Float) Rect {
M internal/core/layout_test.go +3 -2
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "github.com/bnema/nefertty/internal/ports"
45 "math"
56 "reflect"
67 "testing"
@@ -84,7 +85,7 @@ func TestStackAndLayout(t *testing.T) {
8485 }
8586 w.FocusWindow(-1)
8687 p := w.Layout()
87- 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].Borderless {
88+ 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 {
8889 t.Fatal(p)
8990 }
9091 w.ToggleFullscreen()
@@ -444,7 +445,7 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
444445 if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) {
445446 t.Fatal(got[1].Rect, got[2].Rect)
446447 }
447- if got[1].Borderless || got[2].Borderless {
448+ if got[1].Neighbors == 0 || got[2].Neighbors == 0 {
448449 t.Fatal("spiral windows must keep borders to show focus")
449450 }
450451 })
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "github.com/bnema/nefertty/internal/ports"
4 "math"5 "math"
5 "reflect"6 "reflect"
6 "testing"7 "testing"
@@ -84,7 +85,7 @@ func TestStackAndLayout(t *testing.T) {
84 }85 }
85 w.FocusWindow(-1)86 w.FocusWindow(-1)
86 p := w.Layout()87 p := w.Layout()
87- 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].Borderless {88+ 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 {
88 t.Fatal(p)89 t.Fatal(p)
89 }90 }
90 w.ToggleFullscreen()91 w.ToggleFullscreen()
@@ -444,7 +445,7 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
444 if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) {445 if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) {
445 t.Fatal(got[1].Rect, got[2].Rect)446 t.Fatal(got[1].Rect, got[2].Rect)
446 }447 }
447- if got[1].Borderless || got[2].Borderless {448+ if got[1].Neighbors == 0 || got[2].Neighbors == 0 {
448 t.Fatal("spiral windows must keep borders to show focus")449 t.Fatal("spiral windows must keep borders to show focus")
449 }450 }
450 })451 })
M internal/core/monitor_test.go +4 -3
@@ -1,6 +1,7 @@
11 package core
22
33 import (
4+ "github.com/bnema/nefertty/internal/ports"
45 "reflect"
56 "slices"
67 "testing"
@@ -180,13 +181,13 @@ func TestEqualSharesAndBorderless(t *testing.T) {
180181 m.SetMaxColumns(3)
181182 m.AddWindow(1)
182183 p := m.Layout()
183- if p[0].Rect.W != 100 || !p[0].Borderless {
184+ if p[0].Rect.W != 100 || p[0].Neighbors != 0 {
184185 t.Fatal(p)
185186 }
186187 m.AddWindow(2)
187188 m.AddWindow(3)
188189 p = m.Layout()
189- if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless {
190+ if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Neighbors != ports.SideRight || p[0].Inset != ports.SideRight {
190191 t.Fatal(p)
191192 }
192193 // A fourth column scrolls: still 1/3 each.
@@ -307,7 +308,7 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) {
307308 m.SetPresets([]Width{{Num: 1, Den: 2}})
308309 m.AddWindow(1)
309310 m.Apply(ActionCycleColumnWidth)
310- if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless {
311+ if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
311312 t.Fatal(p)
312313 }
313314 }
@@ -1,6 +1,7 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "github.com/bnema/nefertty/internal/ports"
4 "reflect"5 "reflect"
5 "slices"6 "slices"
6 "testing"7 "testing"
@@ -180,13 +181,13 @@ func TestEqualSharesAndBorderless(t *testing.T) {
180 m.SetMaxColumns(3)181 m.SetMaxColumns(3)
181 m.AddWindow(1)182 m.AddWindow(1)
182 p := m.Layout()183 p := m.Layout()
183- if p[0].Rect.W != 100 || !p[0].Borderless {184+ if p[0].Rect.W != 100 || p[0].Neighbors != 0 {
184 t.Fatal(p)185 t.Fatal(p)
185 }186 }
186 m.AddWindow(2)187 m.AddWindow(2)
187 m.AddWindow(3)188 m.AddWindow(3)
188 p = m.Layout()189 p = m.Layout()
189- if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless {190+ if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Neighbors != ports.SideRight || p[0].Inset != ports.SideRight {
190 t.Fatal(p)191 t.Fatal(p)
191 }192 }
192 // A fourth column scrolls: still 1/3 each.193 // A fourth column scrolls: still 1/3 each.
@@ -307,7 +308,7 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) {
307 m.SetPresets([]Width{{Num: 1, Den: 2}})308 m.SetPresets([]Width{{Num: 1, Den: 2}})
308 m.AddWindow(1)309 m.AddWindow(1)
309 m.Apply(ActionCycleColumnWidth)310 m.Apply(ActionCycleColumnWidth)
310- if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless {311+ if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
311 t.Fatal(p)312 t.Fatal(p)
312 }313 }
313 }314 }
M internal/ports/messages.go +6 -3
@@ -614,7 +614,8 @@ func (s Scene) Shows(id WindowID) bool {
614614 return false
615615 }
616616
617-// Border carries the window border style; colors are #rrggbb, "" skips drawing.
617+// Border carries the window border style; colors are #rrggbb, "" skips
618+// drawing. Width is in logical pixels.
618619 type Border struct {
619620 Width int
620621 Active, Inactive string
@@ -760,8 +761,10 @@ type SceneWindow struct {
760761 ID WindowID
761762 Rect Rect
762763 Focused, Fullscreen, Hidden bool
763- // Borderless windows fill the usable width alone; no border is drawn.
764- Borderless bool
764+ // Inset sides carry the border inside Rect. Neighbors are the sides
765+ // touching another tile: the focused window also lights its neighbors'
766+ // border line on the sides it does not inset.
767+ Neighbors, Inset Sides
765768 // Popups are drawn from their content only: no border, no background.
766769 Popup bool
767770 // OverLayers popups hang from a layer surface: drawn over the top and
@@ -614,7 +614,8 @@ func (s Scene) Shows(id WindowID) bool {
614 return false614 return false
615 }615 }
616 616
617-// Border carries the window border style; colors are #rrggbb, "" skips drawing.617+// Border carries the window border style; colors are #rrggbb, "" skips
618+// drawing. Width is in logical pixels.
618 type Border struct {619 type Border struct {
619 Width int620 Width int
620 Active, Inactive string621 Active, Inactive string
@@ -760,8 +761,10 @@ type SceneWindow struct {
760 ID WindowID761 ID WindowID
761 Rect Rect762 Rect Rect
762 Focused, Fullscreen, Hidden bool763 Focused, Fullscreen, Hidden bool
763- // Borderless windows fill the usable width alone; no border is drawn.764+ // Inset sides carry the border inside Rect. Neighbors are the sides
764- Borderless bool765+ // touching another tile: the focused window also lights its neighbors'
766+ // border line on the sides it does not inset.
767+ Neighbors, Inset Sides
765 // Popups are drawn from their content only: no border, no background.768 // Popups are drawn from their content only: no border, no background.
766 Popup bool769 Popup bool
767 // OverLayers popups hang from a layer surface: drawn over the top and770 // OverLayers popups hang from a layer surface: drawn over the top and
M internal/ports/types.go +29 -0
@@ -5,3 +5,32 @@ type WindowID uint64
55
66 // Rect is a pixel rectangle.
77 type Rect struct{ X, Y, W, H int }
8+
9+// Sides is a set of window sides.
10+type Sides uint8
11+
12+const (
13+ SideLeft Sides = 1 << iota
14+ SideRight
15+ SideTop
16+ SideBottom
17+ SideAll = SideLeft | SideRight | SideTop | SideBottom
18+)
19+
20+// Inset shrinks r by b on each side in s.
21+func (r Rect) Inset(s Sides, b int) Rect {
22+ b = min(max(b, 0), r.W/2, r.H/2)
23+ if s&SideLeft != 0 {
24+ r.X, r.W = r.X+b, r.W-b
25+ }
26+ if s&SideRight != 0 {
27+ r.W -= b
28+ }
29+ if s&SideTop != 0 {
30+ r.Y, r.H = r.Y+b, r.H-b
31+ }
32+ if s&SideBottom != 0 {
33+ r.H -= b
34+ }
35+ return r
36+}
@@ -5,3 +5,32 @@ type WindowID uint64
5 5
6 // Rect is a pixel rectangle.6 // Rect is a pixel rectangle.
7 type Rect struct{ X, Y, W, H int }7 type Rect struct{ X, Y, W, H int }
8+
9+// Sides is a set of window sides.
10+type Sides uint8
11+
12+const (
13+ SideLeft Sides = 1 << iota
14+ SideRight
15+ SideTop
16+ SideBottom
17+ SideAll = SideLeft | SideRight | SideTop | SideBottom
18+)
19+
20+// Inset shrinks r by b on each side in s.
21+func (r Rect) Inset(s Sides, b int) Rect {
22+ b = min(max(b, 0), r.W/2, r.H/2)
23+ if s&SideLeft != 0 {
24+ r.X, r.W = r.X+b, r.W-b
25+ }
26+ if s&SideRight != 0 {
27+ r.W -= b
28+ }
29+ if s&SideTop != 0 {
30+ r.Y, r.H = r.Y+b, r.H-b
31+ }
32+ if s&SideBottom != 0 {
33+ r.H -= b
34+ }
35+ return r
36+}

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