merge: focus-separators (tmux-style focus separators)
17 files · +483 -70
M examples/config +6 -4
| @@ -22,14 +22,16 @@ terminal = foot | |||
| 22 | 22 | ||
| 23 | 23 | 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 | + | # 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. | |
| 26 | 28 | border.width = 2 | |
| 27 | - | border.active = #b4befe | |
| 28 | - | border.inactive = | |
| 29 | + | border.active = #808080 | |
| 30 | + | border.inactive = #111111 | |
| 29 | 31 | ||
| 30 | 32 | layout.gaps = 0 | |
| 31 | 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 | 35 | layout.max-columns = 2 | |
| 34 | 36 | # Past max-columns: scroll opens columns to the right; fixed keeps everything | |
| 35 | 37 | # on screen, each new window splitting the newest one (spiral). | |
| @@ -22,14 +22,16 @@ terminal = foot | |||||
| 22 | 22 | ||||
| 23 | background = #111111 | 23 | 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 | + | # 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. | |||
| 26 | border.width = 2 | 28 | border.width = 2 | ||
| 27 | - | border.active = #b4befe | 29 | + | border.active = #808080 |
| 28 | - | border.inactive = | 30 | + | border.inactive = #111111 |
| 29 | 31 | ||||
| 30 | layout.gaps = 0 | 32 | 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 = 2 | 35 | layout.max-columns = 2 | ||
| 34 | # Past max-columns: scroll opens columns to the right; fixed keeps everything | 36 | # 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 +2 -1
| @@ -83,7 +83,8 @@ func Defaults() ports.Config { | |||
| 83 | 83 | c.Xwayland = "xwayland-satellite" | |
| 84 | 84 | c.Background.Color = "#111111" | |
| 85 | 85 | c.Border.Width = 2 | |
| 86 | - | c.Border.Active = "#b4befe" | |
| 86 | + | c.Border.Active = "#808080" | |
| 87 | + | c.Border.Inactive = "#111111" | |
| 87 | 88 | c.Layout.MaxColumns = 2 | |
| 88 | 89 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| 89 | 90 | c.Layout.Overflow = "scroll" | |
| @@ -83,7 +83,8 @@ func Defaults() ports.Config { | |||||
| 83 | c.Xwayland = "xwayland-satellite" | 83 | c.Xwayland = "xwayland-satellite" | ||
| 84 | c.Background.Color = "#111111" | 84 | c.Background.Color = "#111111" | ||
| 85 | c.Border.Width = 2 | 85 | c.Border.Width = 2 | ||
| 86 | - | c.Border.Active = "#b4befe" | 86 | + | c.Border.Active = "#808080" |
| 87 | + | c.Border.Inactive = "#111111" | |||
| 87 | c.Layout.MaxColumns = 2 | 88 | 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 +2 -2
| @@ -91,8 +91,8 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 91 | 91 | return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above" | |
| 92 | 92 | } | |
| 93 | 93 | } | |
| 94 | - | if !pick.Fullscreen && !pick.Borderless && s.Border.Width > 0 { | |
| 95 | - | // The overlay shows the buffer alone; its border would go with it. | |
| 94 | + | if !pick.Fullscreen && len(s.Separators) > 0 { | |
| 95 | + | // The overlay shows the buffer alone; the lines would go with it. | |
| 96 | 96 | return ports.SceneWindow{}, ports.SurfaceContent{}, "border" | |
| 97 | 97 | } | |
| 98 | 98 | c := surfaces[pick.ID] | |
| @@ -91,8 +91,8 @@ 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 && len(s.Separators) > 0 { |
| 95 | - | // The overlay shows the buffer alone; its border would go with it. | 95 | + | // The overlay shows the buffer alone; the lines would go with it. |
| 96 | return ports.SceneWindow{}, ports.SurfaceContent{}, "border" | 96 | return ports.SceneWindow{}, ports.SurfaceContent{}, "border" | ||
| 97 | } | 97 | } | ||
| 98 | c := surfaces[pick.ID] | 98 | c := surfaces[pick.ID] | ||
M internal/adapters/drm/overlay_test.go +1 -1
| @@ -119,7 +119,7 @@ func TestOverlayPopupAndBorder(t *testing.T) { | |||
| 119 | 119 | t.Fatalf("popup: %q", reason) | |
| 120 | 120 | } | |
| 121 | 121 | s, _ = overlayScene() | |
| 122 | - | s.Border.Width = 2 | |
| 122 | + | s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}} | |
| 123 | 123 | if _, _, reason := overlayCandidate(s, c); reason != "border" { | |
| 124 | 124 | t.Fatalf("border: %q", reason) | |
| 125 | 125 | } | |
| @@ -119,7 +119,7 @@ func TestOverlayPopupAndBorder(t *testing.T) { | |||||
| 119 | t.Fatalf("popup: %q", reason) | 119 | t.Fatalf("popup: %q", reason) | ||
| 120 | } | 120 | } | ||
| 121 | s, _ = overlayScene() | 121 | s, _ = overlayScene() | ||
| 122 | - | s.Border.Width = 2 | 122 | + | s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}} |
| 123 | if _, _, reason := overlayCandidate(s, c); reason != "border" { | 123 | if _, _, reason := overlayCandidate(s, c); reason != "border" { | ||
| 124 | t.Fatalf("border: %q", reason) | 124 | t.Fatalf("border: %q", reason) | ||
| 125 | } | 125 | } | ||
M internal/adapters/vulkan/compose_test.go +4 -4
| @@ -30,7 +30,7 @@ func TestRendererScaledSHMFiltersLinearly(t *testing.T) { | |||
| 30 | 30 | px := []byte{0, 0, 0, 255, 255, 255, 255, 255} | |
| 31 | 31 | c := shmContent(t, 2, 1, 8, px) | |
| 32 | 32 | 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 | 34 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil { | |
| 35 | 35 | t.Fatal(err) | |
| 36 | 36 | } | |
| @@ -62,7 +62,7 @@ func TestRendererScaledDMABufFiltersLinearly(t *testing.T) { | |||
| 62 | 62 | return [4]byte{255, 255, 255, 255} | |
| 63 | 63 | }) | |
| 64 | 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 | 66 | c := ports.SurfaceContent{ID: 1, Width: 64, Height: 1, LogicalW: 128, LogicalH: 4, DMABuf: buf} | |
| 67 | 67 | if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil { | |
| 68 | 68 | t.Fatal(err) | |
| @@ -108,7 +108,7 @@ func TestRendererSHMCopiedOncePerContent(t *testing.T) { | |||
| 108 | 108 | defer r.Close() | |
| 109 | 109 | c := solidContent(t, 16, 16, color.RGBA{1, 2, 3, 255}) | |
| 110 | 110 | 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 | 112 | contents := map[ports.WindowID]ports.SurfaceContent{1: c} | |
| 113 | 113 | render := func() { | |
| 114 | 114 | t.Helper() | |
| @@ -160,7 +160,7 @@ func TestRendererSHMDoubleBufferWaitsForReaders(t *testing.T) { | |||
| 160 | 160 | if !r.syncFD { | |
| 161 | 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 | 164 | var readers []uint64 | |
| 165 | 165 | for seq := uint64(1); seq <= 3; seq++ { | |
| 166 | 166 | 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, 16 | 32 | 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, 1 | 110 | 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 []uint64 | 164 | 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) { | |||
| 54 | 54 | base := shmContent(t, w, h, w*4, pixels) | |
| 55 | 55 | base.ID = 1 | |
| 56 | 56 | 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 | 58 | hist := []ports.SeqDamage{{Seq: 1, Full: true}} | |
| 59 | 59 | c := damagedContent(t, f, *base, 1, pixels, hist) | |
| 60 | 60 | step := func(i int, c ports.SurfaceContent) { | |
| @@ -113,7 +113,7 @@ func TestRendererDamageWindowLeftOutAndBack(t *testing.T) { | |||
| 113 | 113 | c := solidContent(t, 16, 16, color.RGBA{200, 10, 10, 255}) | |
| 114 | 114 | c.ID, c.Seq = 1, 1 | |
| 115 | 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 | 117 | without := with | |
| 118 | 118 | without.Windows = nil | |
| 119 | 119 | 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 = 1 | 55 | base.ID = 1 | ||
| 56 | f := base.SHM.File | 56 | 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, 1 | 114 | 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 := with | 117 | without := with | ||
| 118 | without.Windows = nil | 118 | 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 +31 -19
| @@ -422,7 +422,28 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 422 | 422 | } | |
| 423 | 423 | } | |
| 424 | 424 | addLayers(false) | |
| 425 | + | // Separators are drawn in order, active lines over inactive ones: | |
| 426 | + | // tile lines (Window 0) over the tiles, a float's border with it. | |
| 427 | + | addSeparators := func(id ports.WindowID) { | |
| 428 | + | for _, sep := range s.Separators { | |
| 429 | + | if sep.Window != id { | |
| 430 | + | continue | |
| 431 | + | } | |
| 432 | + | col := s.Border.Inactive | |
| 433 | + | if sep.Active { | |
| 434 | + | col = s.Border.Active | |
| 435 | + | } | |
| 436 | + | if col != "" { | |
| 437 | + | add(physRect(sep.Rect.X, sep.Rect.Y, sep.Rect.W, sep.Rect.H), parseColor(col)) | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | tileLines := false | |
| 425 | 442 | for _, w := range s.Windows { | |
| 443 | + | if w.Floating && !tileLines { | |
| 444 | + | addSeparators(0) | |
| 445 | + | tileLines = true | |
| 446 | + | } | |
| 426 | 447 | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | |
| 427 | 448 | continue | |
| 428 | 449 | } | |
| @@ -431,11 +452,12 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 431 | 452 | continue | |
| 432 | 453 | } | |
| 433 | 454 | // 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) | |
| 455 | + | b, inset := 0, ports.Sides(0) | |
| 456 | + | if !w.Fullscreen { | |
| 457 | + | b, inset = s.Border.Width, w.Inset | |
| 437 | 458 | } | |
| 438 | - | cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b | |
| 459 | + | c := w.Rect.Inset(inset, b) | |
| 460 | + | cx, cy, cw, ch := c.X, c.Y, c.W, c.H | |
| 439 | 461 | body := physRect(cx, cy, cw, ch) | |
| 440 | 462 | content := contents[w.ID] | |
| 441 | 463 | dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H)) | |
| @@ -445,23 +467,13 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 445 | 467 | add(body, parseColor(s.Background)) | |
| 446 | 468 | place(w.ID, &content, cx, cy, cw, ch) | |
| 447 | 469 | } | |
| 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) | |
| 462 | - | } | |
| 470 | + | if w.Floating { | |
| 471 | + | addSeparators(w.ID) | |
| 463 | 472 | } | |
| 464 | 473 | } | |
| 474 | + | if !tileLines { | |
| 475 | + | addSeparators(0) | |
| 476 | + | } | |
| 465 | 477 | // Popups draw only what the client drew, shadows clipped. Window popups | |
| 466 | 478 | // stay with the windows; a layer's go over every layer. | |
| 467 | 479 | addPopups := func(overLayers bool) { | |
| @@ -422,7 +422,28 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 422 | } | 422 | } | ||
| 423 | } | 423 | } | ||
| 424 | addLayers(false) | 424 | addLayers(false) | ||
| 425 | + | // Separators are drawn in order, active lines over inactive ones: | |||
| 426 | + | // tile lines (Window 0) over the tiles, a float's border with it. | |||
| 427 | + | addSeparators := func(id ports.WindowID) { | |||
| 428 | + | for _, sep := range s.Separators { | |||
| 429 | + | if sep.Window != id { | |||
| 430 | + | continue | |||
| 431 | + | } | |||
| 432 | + | col := s.Border.Inactive | |||
| 433 | + | if sep.Active { | |||
| 434 | + | col = s.Border.Active | |||
| 435 | + | } | |||
| 436 | + | if col != "" { | |||
| 437 | + | add(physRect(sep.Rect.X, sep.Rect.Y, sep.Rect.W, sep.Rect.H), parseColor(col)) | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | tileLines := false | |||
| 425 | for _, w := range s.Windows { | 442 | for _, w := range s.Windows { | ||
| 443 | + | if w.Floating && !tileLines { | |||
| 444 | + | addSeparators(0) | |||
| 445 | + | tileLines = true | |||
| 446 | + | } | |||
| 426 | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | 447 | if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 { | ||
| 427 | continue | 448 | continue | ||
| 428 | } | 449 | } | ||
| @@ -431,11 +452,12 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 431 | continue | 452 | continue | ||
| 432 | } | 453 | } | ||
| 433 | // Content sits inside the border; core sized the client to match. | 454 | // Content sits inside the border; core sized the client to match. | ||
| 434 | - | b := 0 | 455 | + | b, inset := 0, ports.Sides(0) |
| 435 | - | if !w.Fullscreen && !w.Borderless { | 456 | + | if !w.Fullscreen { |
| 436 | - | b = min(max(s.Border.Width, 0), w.Rect.W/2, w.Rect.H/2) | 457 | + | b, inset = s.Border.Width, w.Inset |
| 437 | } | 458 | } | ||
| 438 | - | cx, cy, cw, ch := x+b, y+b, w.Rect.W-2*b, w.Rect.H-2*b | 459 | + | c := w.Rect.Inset(inset, b) |
| 460 | + | cx, cy, cw, ch := c.X, c.Y, c.W, c.H | |||
| 439 | body := physRect(cx, cy, cw, ch) | 461 | body := physRect(cx, cy, cw, ch) | ||
| 440 | content := contents[w.ID] | 462 | content := contents[w.ID] | ||
| 441 | dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H)) | 463 | dmg.window(w.ID, content, physRect(x, y, w.Rect.W, w.Rect.H)) | ||
| @@ -445,23 +467,13 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 445 | add(body, parseColor(s.Background)) | 467 | add(body, parseColor(s.Background)) | ||
| 446 | place(w.ID, &content, cx, cy, cw, ch) | 468 | place(w.ID, &content, cx, cy, cw, ch) | ||
| 447 | } | 469 | } | ||
| 448 | - | borderColor := s.Border.Inactive | 470 | + | if w.Floating { |
| 449 | - | if w.Focused { | 471 | + | addSeparators(w.ID) |
| 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) | |||
| 462 | - | } | |||
| 463 | } | 472 | } | ||
| 464 | } | 473 | } | ||
| 474 | + | if !tileLines { | |||
| 475 | + | addSeparators(0) | |||
| 476 | + | } | |||
| 465 | // Popups draw only what the client drew, shadows clipped. Window popups | 477 | // Popups draw only what the client drew, shadows clipped. Window popups | ||
| 466 | // stay with the windows; a layer's go over every layer. | 478 | // stay with the windows; a layer's go over every layer. | ||
| 467 | addPopups := func(overLayers bool) { | 479 | addPopups := func(overLayers bool) { | ||
M internal/adapters/vulkan/renderer_test.go +27 -8
| @@ -57,13 +57,31 @@ 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 | + | // The client sits inside its inset sides; separators are drawn over | |
| 61 | + | // the windows in order, with the border colors. | |
| 62 | + | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}, Inset: ports.SideAll} | |
| 61 | 63 | c := windowColor(1) | |
| 62 | 64 | wc := color.RGBA{c[0], c[1], c[2], 255} | |
| 65 | + | gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255} | |
| 63 | 66 | 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}) | |
| 65 | - | 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}) | |
| 67 | + | check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: bg, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 22}: bg, {24, 24}: bg}) | |
| 68 | + | s.Separators = []ports.Separator{ | |
| 69 | + | {Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 2}}, | |
| 70 | + | {Rect: ports.Rect{X: 8, Y: 8, W: 8, H: 2}, Active: true}, | |
| 71 | + | } | |
| 72 | + | check(s, map[image.Point]color.RGBA{{9, 9}: lit, {15, 9}: lit, {16, 9}: gray, {23, 9}: gray, {10, 10}: wc}) | |
| 73 | + | s.Border.Inactive = "" | |
| 74 | + | check(s, map[image.Point]color.RGBA{{9, 9}: lit, {16, 9}: bg}) | |
| 75 | + | // A float covers the tile lines; its own border is drawn with it. | |
| 76 | + | s.Windows = []ports.SceneWindow{win, {ID: 3, Rect: ports.Rect{X: 12, Y: 4, W: 8, H: 8}, Floating: true, Inset: ports.SideAll}} | |
| 77 | + | s.Separators = []ports.Separator{ | |
| 78 | + | {Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 2}, Active: true}, | |
| 79 | + | {Rect: ports.Rect{X: 12, Y: 4, W: 8, H: 1}, Active: true, Window: 3}, | |
| 80 | + | } | |
| 81 | + | fc := windowColor(3) | |
| 82 | + | check(s, map[image.Point]color.RGBA{{9, 9}: lit, {14, 9}: {fc[0], fc[1], fc[2], 255}, {14, 4}: lit}) | |
| 83 | + | s.Windows = []ports.SceneWindow{win} | |
| 84 | + | s.Separators, s.Border.Inactive = nil, "#313244" | |
| 67 | 85 | s.Windows[0].Fullscreen = true | |
| 68 | 86 | check(s, map[image.Point]color.RGBA{{9, 9}: wc}) | |
| 69 | 87 | s.Windows[0].Fullscreen = false | |
| @@ -135,7 +153,8 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) { | |||
| 135 | 153 | t.Errorf("overlap = %v, want %v", got, want) | |
| 136 | 154 | } | |
| 137 | 155 | 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}} | |
| 156 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}} | |
| 157 | + | scene.Separators = []ports.Separator{{Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 4}, Active: true}} | |
| 139 | 158 | px := make([]byte, 16*16*4) | |
| 140 | 159 | for i := 0; i < len(px); i += 4 { | |
| 141 | 160 | copy(px[i:i+4], []byte{3, 5, 7, 0}) | |
| @@ -214,7 +233,7 @@ func TestRendererScale(t *testing.T) { | |||
| 214 | 233 | red := [3]uint8{200, 0, 0} | |
| 215 | 234 | // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28); | |
| 216 | 235 | // 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}}} | |
| 236 | + | s := ports.Scene{Scale: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 10, H: 10}}}} | |
| 218 | 237 | scaled := shmContent(t, 20, 20, 80, solid(20, 20, red)) | |
| 219 | 238 | scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10 | |
| 220 | 239 | content := map[ports.WindowID]ports.SurfaceContent{1: *scaled} | |
| @@ -299,7 +318,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) { | |||
| 299 | 318 | defer r.Close() | |
| 300 | 319 | red, green, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}, color.RGBA{0, 0, 255, 255} | |
| 301 | 320 | 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}}} | |
| 321 | + | scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 10, Y: 10, W: 20, H: 10}}}} | |
| 303 | 322 | // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow. | |
| 304 | 323 | root := solidContent(t, 30, 20, red) | |
| 305 | 324 | root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10} | |
| @@ -396,7 +415,7 @@ func TestRendererAlphaBlend(t *testing.T) { | |||
| 396 | 415 | 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)), | |
| 397 | 416 | } | |
| 398 | 417 | scene := ports.Scene{Background: "#000000", | |
| 399 | - | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}, Borderless: true}}, | |
| 418 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}}}, | |
| 400 | 419 | Layers: []ports.SceneLayer{ | |
| 401 | 420 | {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}}, | |
| 402 | 421 | {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}}, | |
| @@ -57,13 +57,31 @@ 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 | + | // The client sits inside its inset sides; separators are drawn over |
| 61 | + | // the windows in order, with the border colors. | |||
| 62 | + | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}, Inset: ports.SideAll} | |||
| 61 | c := windowColor(1) | 63 | c := windowColor(1) | ||
| 62 | wc := color.RGBA{c[0], c[1], c[2], 255} | 64 | wc := color.RGBA{c[0], c[1], c[2], 255} | ||
| 65 | + | gray, lit := color.RGBA{0x31, 0x32, 0x44, 255}, color.RGBA{0xb4, 0xbe, 0xfe, 255} | |||
| 63 | s := ports.Scene{Background: "#102030", Border: ports.Border{Width: 2, Active: "#b4befe", Inactive: "#313244"}, Windows: []ports.SceneWindow{win}} | 66 | 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}) | 67 | + | check(s, map[image.Point]color.RGBA{{0, 0}: bg, {9, 9}: bg, {10, 10}: wc, {16, 16}: wc, {21, 21}: wc, {22, 22}: bg, {24, 24}: bg}) |
| 65 | - | s.Windows[0].Focused = true | 68 | + | s.Separators = []ports.Separator{ |
| 66 | - | check(s, map[image.Point]color.RGBA{{9, 9}: {0xb4, 0xbe, 0xfe, 255}, {10, 10}: wc, {16, 16}: wc}) | 69 | + | {Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 2}}, |
| 70 | + | {Rect: ports.Rect{X: 8, Y: 8, W: 8, H: 2}, Active: true}, | |||
| 71 | + | } | |||
| 72 | + | check(s, map[image.Point]color.RGBA{{9, 9}: lit, {15, 9}: lit, {16, 9}: gray, {23, 9}: gray, {10, 10}: wc}) | |||
| 73 | + | s.Border.Inactive = "" | |||
| 74 | + | check(s, map[image.Point]color.RGBA{{9, 9}: lit, {16, 9}: bg}) | |||
| 75 | + | // A float covers the tile lines; its own border is drawn with it. | |||
| 76 | + | s.Windows = []ports.SceneWindow{win, {ID: 3, Rect: ports.Rect{X: 12, Y: 4, W: 8, H: 8}, Floating: true, Inset: ports.SideAll}} | |||
| 77 | + | s.Separators = []ports.Separator{ | |||
| 78 | + | {Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 2}, Active: true}, | |||
| 79 | + | {Rect: ports.Rect{X: 12, Y: 4, W: 8, H: 1}, Active: true, Window: 3}, | |||
| 80 | + | } | |||
| 81 | + | fc := windowColor(3) | |||
| 82 | + | check(s, map[image.Point]color.RGBA{{9, 9}: lit, {14, 9}: {fc[0], fc[1], fc[2], 255}, {14, 4}: lit}) | |||
| 83 | + | s.Windows = []ports.SceneWindow{win} | |||
| 84 | + | s.Separators, s.Border.Inactive = nil, "#313244" | |||
| 67 | s.Windows[0].Fullscreen = true | 85 | s.Windows[0].Fullscreen = true | ||
| 68 | check(s, map[image.Point]color.RGBA{{9, 9}: wc}) | 86 | check(s, map[image.Point]color.RGBA{{9, 9}: wc}) | ||
| 69 | s.Windows[0].Fullscreen = false | 87 | s.Windows[0].Fullscreen = false | ||
| @@ -135,7 +153,8 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) { | |||||
| 135 | t.Errorf("overlap = %v, want %v", got, want) | 153 | t.Errorf("overlap = %v, want %v", got, want) | ||
| 136 | } | 154 | } | ||
| 137 | scene.Border = ports.Border{Width: 4, Active: "#ffffff"} | 155 | 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}} | 156 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}, Focused: true, Inset: ports.SideAll}} |
| 157 | + | scene.Separators = []ports.Separator{{Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 4}, Active: true}} | |||
| 139 | px := make([]byte, 16*16*4) | 158 | px := make([]byte, 16*16*4) | ||
| 140 | for i := 0; i < len(px); i += 4 { | 159 | for i := 0; i < len(px); i += 4 { | ||
| 141 | copy(px[i:i+4], []byte{3, 5, 7, 0}) | 160 | copy(px[i:i+4], []byte{3, 5, 7, 0}) | ||
| @@ -214,7 +233,7 @@ func TestRendererScale(t *testing.T) { | |||||
| 214 | red := [3]uint8{200, 0, 0} | 233 | red := [3]uint8{200, 0, 0} | ||
| 215 | // Scale 2: a 10x10 logical window at (4,4) covers physical (8,8)-(28,28); | 234 | // 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. | 235 | // 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}}} | 236 | + | 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)) | 237 | scaled := shmContent(t, 20, 20, 80, solid(20, 20, red)) | ||
| 219 | scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10 | 238 | scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10 | ||
| 220 | content := map[ports.WindowID]ports.SurfaceContent{1: *scaled} | 239 | content := map[ports.WindowID]ports.SurfaceContent{1: *scaled} | ||
| @@ -299,7 +318,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) { | |||||
| 299 | defer r.Close() | 318 | 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} | 319 | 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} | 320 | 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}}} | 321 | + | 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. | 322 | // A 30×20 surface whose window is the 20×10 at (5, 5): a 5px shadow. | ||
| 304 | root := solidContent(t, 30, 20, red) | 323 | root := solidContent(t, 30, 20, red) | ||
| 305 | root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10} | 324 | root.Geometry = ports.Rect{X: 5, Y: 5, W: 20, H: 10} | ||
| @@ -396,7 +415,7 @@ func TestRendererAlphaBlend(t *testing.T) { | |||||
| 396 | 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)), | 415 | 4: *shmContent(t, 16, 8, 16*4, fill(16, 8, halfBlue)), | ||
| 397 | } | 416 | } | ||
| 398 | scene := ports.Scene{Background: "#000000", | 417 | scene := ports.Scene{Background: "#000000", | ||
| 399 | - | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}, Borderless: true}}, | 418 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 48}}}, |
| 400 | Layers: []ports.SceneLayer{ | 419 | Layers: []ports.SceneLayer{ | ||
| 401 | {ID: 2, Layer: ports.LayerBottom, Rect: ports.Rect{W: 64, H: 48}}, | 420 | {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}}, | 421 | {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}}, | ||
M internal/core/core.go +10 -9
| @@ -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 | 296 | // The renderer applies the same inset (ports.Border). | |
| 297 | 297 | 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 | |
| 301 | 300 | } | |
| 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) | |
| 304 | 302 | } | |
| 305 | 303 | ||
| 306 | 304 | // visible reports whether the window, layer surface or popup is on screen | |
| @@ -401,11 +399,14 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 401 | 399 | c.seq++ | |
| 402 | 400 | o := sc.mon.Output() | |
| 403 | 401 | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, 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), Layers: append([]ports.SceneLayer(nil), sc.placed...)} | |
| 404 | - | for _, p := range sc.mon.Layout() { | |
| 402 | + | layout := sc.mon.Layout() | |
| 403 | + | // Only the focused output lights the focused window's lines. | |
| 404 | + | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen) | |
| 405 | + | for _, p := range layout { | |
| 405 | 406 | alive[p.ID] = true | |
| 406 | - | // Only the focused output shows the focused border. | |
| 407 | + | // Only the focused output has an activated window. | |
| 407 | 408 | 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}) | |
| 409 | + | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset}) | |
| 409 | 410 | floating := p.Floating | |
| 410 | 411 | if p.Hidden { | |
| 411 | 412 | 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.Rect | 298 | + | 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 screen | 304 | // visible reports whether the window, layer surface or popup is on screen | ||
| @@ -401,11 +399,14 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 401 | c.seq++ | 399 | c.seq++ | ||
| 402 | o := sc.mon.Output() | 400 | o := sc.mon.Output() | ||
| 403 | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, 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), Layers: append([]ports.SceneLayer(nil), sc.placed...)} | 401 | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, 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), Layers: append([]ports.SceneLayer(nil), sc.placed...)} | ||
| 404 | - | for _, p := range sc.mon.Layout() { | 402 | + | layout := sc.mon.Layout() |
| 403 | + | // Only the focused output lights the focused window's lines. | |||
| 404 | + | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen) | |||
| 405 | + | for _, p := range layout { | |||
| 405 | alive[p.ID] = true | 406 | alive[p.ID] = true | ||
| 406 | - | // Only the focused output shows the focused border. | 407 | + | // Only the focused output has an activated window. |
| 407 | focused := p.Focused && i == c.focusScreen | 408 | 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}) | 409 | + | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset}) |
| 409 | floating := p.Floating | 410 | floating := p.Floating | ||
| 410 | if p.Hidden { | 411 | if p.Hidden { | ||
| 411 | if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") { | 412 | 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) { | |||
| 69 | 69 | client <- ports.WindowMapped{ID: 1} | |
| 70 | 70 | s = scene(t, scenes) | |
| 71 | 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 | 73 | t.Fatal(s) | |
| 74 | 74 | } | |
| 75 | 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 | 301 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) { | |
| 302 | 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 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. | |
| 306 | 306 | client <- ports.WindowMapped{ID: 2} | |
| 307 | 307 | s := scene(t, scenes) | |
| 308 | 308 | 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 || len(s.Separators) == 0 { | |
| 310 | 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 | 313 | found := false | |
| 314 | 314 | for len(commands) > 0 { | |
| 315 | 315 | if v := <-commands; v == want { | |
| @@ -320,7 +320,7 @@ func TestBorderInset(t *testing.T) { | |||
| 320 | 320 | t.Fatalf("no %v for outer %v", want, r) | |
| 321 | 321 | } | |
| 322 | 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 | 324 | t.Fatal(v) | |
| 325 | 325 | } | |
| 326 | 326 | } | |
| @@ -421,7 +421,7 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 421 | 421 | // A new window opens on workspace 2, alone and borderless. | |
| 422 | 422 | client <- ports.WindowMapped{ID: 2} | |
| 423 | 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 | 425 | t.Fatal(s) | |
| 426 | 426 | } | |
| 427 | 427 | } | |
| @@ -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 is | 304 | + | // 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].Rect | 308 | 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 || len(s.Separators) == 0 { |
| 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 := false | 313 | 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 { | |||
| 93 | 93 | Fullscreen, Focused, Hidden bool | |
| 94 | 94 | // Floating windows sit over the columns at their own size. | |
| 95 | 95 | 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 | |
| 99 | 102 | } | |
| 100 | 103 | ||
| 101 | 104 | // Overflow says what happens past MaxColumns columns. | |
| @@ -665,13 +668,14 @@ func (w *Workspace) Layout() []Placement { | |||
| 665 | 668 | r = Rect{} | |
| 666 | 669 | } | |
| 667 | 670 | 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 | 672 | y += h + gap | |
| 670 | 673 | } | |
| 671 | 674 | } | |
| 675 | + | setNeighbors(result, gap) | |
| 672 | 676 | // Floating windows go last: they are drawn and hit on top. | |
| 673 | 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 | 679 | // Floats stay above a fullscreen window: a dialog opened from a | |
| 676 | 680 | // fullscreen app must be seen. | |
| 677 | 681 | if w.fullscreen == f.ID { | |
| @@ -682,6 +686,39 @@ func (w *Workspace) Layout() []Placement { | |||
| 682 | 686 | return result | |
| 683 | 687 | } | |
| 684 | 688 | ||
| 689 | + | // setNeighbors marks the sides where tiles touch across the gap, from | |
| 690 | + | // geometry alone: client sizes do not change as the view scrolls. | |
| 691 | + | func setNeighbors(tiles []Placement, gap int) { | |
| 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 { | |
| 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 | 722 | // floatRect centres a floating window in the usable area, its border | |
| 686 | 723 | // around its client size, clamped to the area. | |
| 687 | 724 | func (w *Workspace) floatRect(f Float) Rect { | |
| @@ -93,9 +93,12 @@ type Placement struct { | |||||
| 93 | Fullscreen, Focused, Hidden bool | 93 | 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 bool | 95 | Floating bool | ||
| 96 | - | // Borderless is set when the column fills the usable width: it is the only | 96 | + | // 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 bool | 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 | |||
| 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.Focus | 670 | 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 + gap | 672 | y += h + gap | ||
| 670 | } | 673 | } | ||
| 671 | } | 674 | } | ||
| 675 | + | setNeighbors(result, gap) | |||
| 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 a | 679 | // 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 result | 686 | return result | ||
| 683 | } | 687 | } | ||
| 684 | 688 | ||||
| 689 | + | // setNeighbors marks the sides where tiles touch across the gap, from | |||
| 690 | + | // geometry alone: client sizes do not change as the view scrolls. | |||
| 691 | + | func setNeighbors(tiles []Placement, gap int) { | |||
| 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 { | |||
| 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 border | 722 | // 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 +4 -2
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "math" | |
| 5 | 5 | "reflect" | |
| 6 | 6 | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | func workspace() *Workspace { | |
| @@ -84,7 +86,7 @@ func TestStackAndLayout(t *testing.T) { | |||
| 84 | 86 | } | |
| 85 | 87 | w.FocusWindow(-1) | |
| 86 | 88 | 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 { | |
| 89 | + | 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 | 90 | t.Fatal(p) | |
| 89 | 91 | } | |
| 90 | 92 | w.ToggleFullscreen() | |
| @@ -444,7 +446,7 @@ func TestFixedOverflowEdgeCases(t *testing.T) { | |||
| 444 | 446 | 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 | 447 | t.Fatal(got[1].Rect, got[2].Rect) | |
| 446 | 448 | } | |
| 447 | - | if got[1].Borderless || got[2].Borderless { | |
| 449 | + | if got[1].Neighbors == 0 || got[2].Neighbors == 0 { | |
| 448 | 450 | t.Fatal("spiral windows must keep borders to show focus") | |
| 449 | 451 | } | |
| 450 | 452 | }) | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "math" | 4 | "math" | ||
| 5 | "reflect" | 5 | "reflect" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | func workspace() *Workspace { | 11 | func workspace() *Workspace { | ||
| @@ -84,7 +86,7 @@ func TestStackAndLayout(t *testing.T) { | |||||
| 84 | } | 86 | } | ||
| 85 | w.FocusWindow(-1) | 87 | w.FocusWindow(-1) | ||
| 86 | p := w.Layout() | 88 | 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 { | 89 | + | 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) | 90 | t.Fatal(p) | ||
| 89 | } | 91 | } | ||
| 90 | w.ToggleFullscreen() | 92 | w.ToggleFullscreen() | ||
| @@ -444,7 +446,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}) { | 446 | 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) | 447 | t.Fatal(got[1].Rect, got[2].Rect) | ||
| 446 | } | 448 | } | ||
| 447 | - | if got[1].Borderless || got[2].Borderless { | 449 | + | if got[1].Neighbors == 0 || got[2].Neighbors == 0 { |
| 448 | t.Fatal("spiral windows must keep borders to show focus") | 450 | t.Fatal("spiral windows must keep borders to show focus") | ||
| 449 | } | 451 | } | ||
| 450 | }) | 452 | }) | ||
M internal/core/monitor_test.go +5 -3
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "reflect" | |
| 5 | 5 | "slices" | |
| 6 | 6 | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | func monitor() *Monitor { | |
| @@ -180,13 +182,13 @@ func TestEqualSharesAndBorderless(t *testing.T) { | |||
| 180 | 182 | m.SetMaxColumns(3) | |
| 181 | 183 | m.AddWindow(1) | |
| 182 | 184 | p := m.Layout() | |
| 183 | - | if p[0].Rect.W != 100 || !p[0].Borderless { | |
| 185 | + | if p[0].Rect.W != 100 || p[0].Neighbors != 0 { | |
| 184 | 186 | t.Fatal(p) | |
| 185 | 187 | } | |
| 186 | 188 | m.AddWindow(2) | |
| 187 | 189 | m.AddWindow(3) | |
| 188 | 190 | p = m.Layout() | |
| 189 | - | if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless { | |
| 191 | + | 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 | 192 | t.Fatal(p) | |
| 191 | 193 | } | |
| 192 | 194 | // A fourth column scrolls: still 1/3 each. | |
| @@ -307,7 +309,7 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||
| 307 | 309 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |
| 308 | 310 | m.AddWindow(1) | |
| 309 | 311 | m.Apply(ActionCycleColumnWidth) | |
| 310 | - | if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless { | |
| 312 | + | if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 { | |
| 311 | 313 | t.Fatal(p) | |
| 312 | 314 | } | |
| 313 | 315 | } | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "reflect" | 4 | "reflect" | ||
| 5 | "slices" | 5 | "slices" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | func monitor() *Monitor { | 11 | func monitor() *Monitor { | ||
| @@ -180,13 +182,13 @@ func TestEqualSharesAndBorderless(t *testing.T) { | |||||
| 180 | m.SetMaxColumns(3) | 182 | m.SetMaxColumns(3) | ||
| 181 | m.AddWindow(1) | 183 | m.AddWindow(1) | ||
| 182 | p := m.Layout() | 184 | p := m.Layout() | ||
| 183 | - | if p[0].Rect.W != 100 || !p[0].Borderless { | 185 | + | if p[0].Rect.W != 100 || p[0].Neighbors != 0 { |
| 184 | t.Fatal(p) | 186 | t.Fatal(p) | ||
| 185 | } | 187 | } | ||
| 186 | m.AddWindow(2) | 188 | m.AddWindow(2) | ||
| 187 | m.AddWindow(3) | 189 | m.AddWindow(3) | ||
| 188 | p = m.Layout() | 190 | p = m.Layout() | ||
| 189 | - | if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Borderless { | 191 | + | 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) | 192 | t.Fatal(p) | ||
| 191 | } | 193 | } | ||
| 192 | // A fourth column scrolls: still 1/3 each. | 194 | // A fourth column scrolls: still 1/3 each. | ||
| @@ -307,7 +309,7 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||||
| 307 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | 309 | m.SetPresets([]Width{{Num: 1, Den: 2}}) | ||
| 308 | m.AddWindow(1) | 310 | m.AddWindow(1) | ||
| 309 | m.Apply(ActionCycleColumnWidth) | 311 | m.Apply(ActionCycleColumnWidth) | ||
| 310 | - | if p := m.Layout()[0]; p.Rect.W != 50 || p.Borderless { | 312 | + | if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 { |
| 311 | t.Fatal(p) | 313 | t.Fatal(p) | ||
| 312 | } | 314 | } | ||
| 313 | } | 315 | } | ||
A internal/core/separators.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/nefertty/internal/ports" | |
| 4 | + | ||
| 5 | + | // Separator lines follow tmux (screen-redraw.c): tiles share one line per | |
| 6 | + | // side where they touch, owned by the left or top tile (Placement.Inset). | |
| 7 | + | // The focused tile lights the lines along its sides, corners included. | |
| 8 | + | // With exactly two tiles one line splits them, and each lights only its | |
| 9 | + | // half of it: the left or top tile the first half. With gaps each tile | |
| 10 | + | // has lines of its own and lights them whole. Floats get a full border | |
| 11 | + | // of their own, drawn with them (Separator.Window). | |
| 12 | + | // | |
| 13 | + | // Only what is on the output counts: lines at the output edge (a column | |
| 14 | + | // scrolled off beyond it) are not drawn, and only tiles on it count. | |
| 15 | + | ||
| 16 | + | var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} | |
| 17 | + | ||
| 18 | + | // separators are the lines of a layout on output o, inactive ones first | |
| 19 | + | // so active ones draw over them. lit says whether the focused tile shows | |
| 20 | + | // focus (only on the focused output). | |
| 21 | + | func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separator { | |
| 22 | + | if width <= 0 { | |
| 23 | + | return nil | |
| 24 | + | } | |
| 25 | + | var out []ports.Separator | |
| 26 | + | add := func(p *Placement, r Rect, active bool) { | |
| 27 | + | if !p.Floating && (r.X < o.X+width || r.X+r.W > o.X+o.W-width) && r.H > r.W { | |
| 28 | + | return // a vertical line at the output edge | |
| 29 | + | } | |
| 30 | + | var id WindowID | |
| 31 | + | if p.Floating { | |
| 32 | + | id = p.ID | |
| 33 | + | } | |
| 34 | + | out = append(out, ports.Separator{Rect: r, Active: active, Window: id}) | |
| 35 | + | } | |
| 36 | + | var focused *Placement | |
| 37 | + | tiles := 0 | |
| 38 | + | for i := range ps { | |
| 39 | + | p := &ps[i] | |
| 40 | + | if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 { | |
| 41 | + | continue | |
| 42 | + | } | |
| 43 | + | if p.Neighbors != 0 && p.Rect.X < o.X+o.W && p.Rect.X+p.Rect.W > o.X { | |
| 44 | + | tiles++ | |
| 45 | + | } | |
| 46 | + | for _, s := range sides { | |
| 47 | + | if p.Inset&s != 0 { | |
| 48 | + | add(p, strip(p.Rect, s, 0, width), false) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | if p.Focused && lit { | |
| 52 | + | focused = p | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | if focused == nil { | |
| 56 | + | return out | |
| 57 | + | } | |
| 58 | + | f := focused | |
| 59 | + | for _, s := range sides { | |
| 60 | + | var line ports.Rect | |
| 61 | + | switch { | |
| 62 | + | case f.Neighbors&s != 0 && f.Inset&s != 0: | |
| 63 | + | line = strip(f.Rect, s, 0, width) | |
| 64 | + | case f.Neighbors&s != 0: | |
| 65 | + | line = strip(f.Rect, s, width, width) | |
| 66 | + | case f.Inset&s != 0: | |
| 67 | + | // A float's own border. | |
| 68 | + | add(f, strip(f.Rect, s, 0, width), true) | |
| 69 | + | continue | |
| 70 | + | default: | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | line = closeCorners(line, *f, s, width) | |
| 74 | + | // Two tiles share one line only without gaps. | |
| 75 | + | if tiles == 2 && gap == 0 { | |
| 76 | + | line = half(line, s) | |
| 77 | + | } | |
| 78 | + | add(f, line, true) | |
| 79 | + | } | |
| 80 | + | return out | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // strip is the b-wide strip along side s of r: inside r when out is 0, | |
| 84 | + | // else just outside it (the neighbor's line). | |
| 85 | + | func strip(r Rect, s ports.Sides, out, b int) Rect { | |
| 86 | + | b = min(b, max(r.W/2, out), max(r.H/2, out)) | |
| 87 | + | switch s { | |
| 88 | + | case ports.SideLeft: | |
| 89 | + | return Rect{X: r.X - out, Y: r.Y, W: b, H: r.H} | |
| 90 | + | case ports.SideRight: | |
| 91 | + | return Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H} | |
| 92 | + | case ports.SideTop: | |
| 93 | + | return Rect{X: r.X, Y: r.Y - out, W: r.W, H: b} | |
| 94 | + | } | |
| 95 | + | return Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b} | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // closeCorners runs a line along side s of p b further at each end where | |
| 99 | + | // the crossing line lies outside p (a neighbor's), so lit lines meet. | |
| 100 | + | func closeCorners(line Rect, p Placement, s ports.Sides, b int) Rect { | |
| 101 | + | outside := func(s ports.Sides) bool { return p.Neighbors&s != 0 && p.Inset&s == 0 } | |
| 102 | + | if s == ports.SideLeft || s == ports.SideRight { | |
| 103 | + | if outside(ports.SideTop) { | |
| 104 | + | line.Y, line.H = line.Y-b, line.H+b | |
| 105 | + | } | |
| 106 | + | if outside(ports.SideBottom) { | |
| 107 | + | line.H += b | |
| 108 | + | } | |
| 109 | + | return line | |
| 110 | + | } | |
| 111 | + | if outside(ports.SideLeft) { | |
| 112 | + | line.X, line.W = line.X-b, line.W+b | |
| 113 | + | } | |
| 114 | + | if outside(ports.SideRight) { | |
| 115 | + | line.W += b | |
| 116 | + | } | |
| 117 | + | return line | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // half keeps the half of a line owned by the tile on side s of it: the | |
| 121 | + | // first half for the left or top tile, else the second. | |
| 122 | + | func half(r Rect, s ports.Sides) Rect { | |
| 123 | + | switch s { | |
| 124 | + | case ports.SideRight: | |
| 125 | + | r.H /= 2 | |
| 126 | + | case ports.SideLeft: | |
| 127 | + | r.Y, r.H = r.Y+r.H/2, r.H-r.H/2 | |
| 128 | + | case ports.SideBottom: | |
| 129 | + | r.W /= 2 | |
| 130 | + | case ports.SideTop: | |
| 131 | + | r.X, r.W = r.X+r.W/2, r.W-r.W/2 | |
| 132 | + | } | |
| 133 | + | return r | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/nefertty/internal/ports" | |||
| 4 | + | ||||
| 5 | + | // Separator lines follow tmux (screen-redraw.c): tiles share one line per | |||
| 6 | + | // side where they touch, owned by the left or top tile (Placement.Inset). | |||
| 7 | + | // The focused tile lights the lines along its sides, corners included. | |||
| 8 | + | // With exactly two tiles one line splits them, and each lights only its | |||
| 9 | + | // half of it: the left or top tile the first half. With gaps each tile | |||
| 10 | + | // has lines of its own and lights them whole. Floats get a full border | |||
| 11 | + | // of their own, drawn with them (Separator.Window). | |||
| 12 | + | // | |||
| 13 | + | // Only what is on the output counts: lines at the output edge (a column | |||
| 14 | + | // scrolled off beyond it) are not drawn, and only tiles on it count. | |||
| 15 | + | ||||
| 16 | + | var sides = [...]ports.Sides{ports.SideLeft, ports.SideRight, ports.SideTop, ports.SideBottom} | |||
| 17 | + | ||||
| 18 | + | // separators are the lines of a layout on output o, inactive ones first | |||
| 19 | + | // so active ones draw over them. lit says whether the focused tile shows | |||
| 20 | + | // focus (only on the focused output). | |||
| 21 | + | func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separator { | |||
| 22 | + | if width <= 0 { | |||
| 23 | + | return nil | |||
| 24 | + | } | |||
| 25 | + | var out []ports.Separator | |||
| 26 | + | add := func(p *Placement, r Rect, active bool) { | |||
| 27 | + | if !p.Floating && (r.X < o.X+width || r.X+r.W > o.X+o.W-width) && r.H > r.W { | |||
| 28 | + | return // a vertical line at the output edge | |||
| 29 | + | } | |||
| 30 | + | var id WindowID | |||
| 31 | + | if p.Floating { | |||
| 32 | + | id = p.ID | |||
| 33 | + | } | |||
| 34 | + | out = append(out, ports.Separator{Rect: r, Active: active, Window: id}) | |||
| 35 | + | } | |||
| 36 | + | var focused *Placement | |||
| 37 | + | tiles := 0 | |||
| 38 | + | for i := range ps { | |||
| 39 | + | p := &ps[i] | |||
| 40 | + | if p.Hidden || p.Fullscreen || p.Rect.W <= 0 || p.Rect.H <= 0 { | |||
| 41 | + | continue | |||
| 42 | + | } | |||
| 43 | + | if p.Neighbors != 0 && p.Rect.X < o.X+o.W && p.Rect.X+p.Rect.W > o.X { | |||
| 44 | + | tiles++ | |||
| 45 | + | } | |||
| 46 | + | for _, s := range sides { | |||
| 47 | + | if p.Inset&s != 0 { | |||
| 48 | + | add(p, strip(p.Rect, s, 0, width), false) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | if p.Focused && lit { | |||
| 52 | + | focused = p | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | if focused == nil { | |||
| 56 | + | return out | |||
| 57 | + | } | |||
| 58 | + | f := focused | |||
| 59 | + | for _, s := range sides { | |||
| 60 | + | var line ports.Rect | |||
| 61 | + | switch { | |||
| 62 | + | case f.Neighbors&s != 0 && f.Inset&s != 0: | |||
| 63 | + | line = strip(f.Rect, s, 0, width) | |||
| 64 | + | case f.Neighbors&s != 0: | |||
| 65 | + | line = strip(f.Rect, s, width, width) | |||
| 66 | + | case f.Inset&s != 0: | |||
| 67 | + | // A float's own border. | |||
| 68 | + | add(f, strip(f.Rect, s, 0, width), true) | |||
| 69 | + | continue | |||
| 70 | + | default: | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 73 | + | line = closeCorners(line, *f, s, width) | |||
| 74 | + | // Two tiles share one line only without gaps. | |||
| 75 | + | if tiles == 2 && gap == 0 { | |||
| 76 | + | line = half(line, s) | |||
| 77 | + | } | |||
| 78 | + | add(f, line, true) | |||
| 79 | + | } | |||
| 80 | + | return out | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // strip is the b-wide strip along side s of r: inside r when out is 0, | |||
| 84 | + | // else just outside it (the neighbor's line). | |||
| 85 | + | func strip(r Rect, s ports.Sides, out, b int) Rect { | |||
| 86 | + | b = min(b, max(r.W/2, out), max(r.H/2, out)) | |||
| 87 | + | switch s { | |||
| 88 | + | case ports.SideLeft: | |||
| 89 | + | return Rect{X: r.X - out, Y: r.Y, W: b, H: r.H} | |||
| 90 | + | case ports.SideRight: | |||
| 91 | + | return Rect{X: r.X + r.W - b + out, Y: r.Y, W: b, H: r.H} | |||
| 92 | + | case ports.SideTop: | |||
| 93 | + | return Rect{X: r.X, Y: r.Y - out, W: r.W, H: b} | |||
| 94 | + | } | |||
| 95 | + | return Rect{X: r.X, Y: r.Y + r.H - b + out, W: r.W, H: b} | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // closeCorners runs a line along side s of p b further at each end where | |||
| 99 | + | // the crossing line lies outside p (a neighbor's), so lit lines meet. | |||
| 100 | + | func closeCorners(line Rect, p Placement, s ports.Sides, b int) Rect { | |||
| 101 | + | outside := func(s ports.Sides) bool { return p.Neighbors&s != 0 && p.Inset&s == 0 } | |||
| 102 | + | if s == ports.SideLeft || s == ports.SideRight { | |||
| 103 | + | if outside(ports.SideTop) { | |||
| 104 | + | line.Y, line.H = line.Y-b, line.H+b | |||
| 105 | + | } | |||
| 106 | + | if outside(ports.SideBottom) { | |||
| 107 | + | line.H += b | |||
| 108 | + | } | |||
| 109 | + | return line | |||
| 110 | + | } | |||
| 111 | + | if outside(ports.SideLeft) { | |||
| 112 | + | line.X, line.W = line.X-b, line.W+b | |||
| 113 | + | } | |||
| 114 | + | if outside(ports.SideRight) { | |||
| 115 | + | line.W += b | |||
| 116 | + | } | |||
| 117 | + | return line | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // half keeps the half of a line owned by the tile on side s of it: the | |||
| 121 | + | // first half for the left or top tile, else the second. | |||
| 122 | + | func half(r Rect, s ports.Sides) Rect { | |||
| 123 | + | switch s { | |||
| 124 | + | case ports.SideRight: | |||
| 125 | + | r.H /= 2 | |||
| 126 | + | case ports.SideLeft: | |||
| 127 | + | r.Y, r.H = r.Y+r.H/2, r.H-r.H/2 | |||
| 128 | + | case ports.SideBottom: | |||
| 129 | + | r.W /= 2 | |||
| 130 | + | case ports.SideTop: | |||
| 131 | + | r.X, r.W = r.X+r.W/2, r.W-r.W/2 | |||
| 132 | + | } | |||
| 133 | + | return r | |||
| 134 | + | } | |||
A internal/core/separators_test.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // tiles lays out rects edge to edge (no gap) with focus on index f. | |
| 11 | + | func tiles(f int, rs ...Rect) []Placement { | |
| 12 | + | ps := make([]Placement, len(rs)) | |
| 13 | + | for i, r := range rs { | |
| 14 | + | ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f} | |
| 15 | + | } | |
| 16 | + | setNeighbors(ps, 0) | |
| 17 | + | return ps | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // bigOutput is an output wider than every test layout. | |
| 21 | + | var bigOutput = Rect{W: 1000, H: 1000} | |
| 22 | + | ||
| 23 | + | // colorAt is what the renderer shows at (x, y): the last separator over | |
| 24 | + | // it wins. "" is no line. | |
| 25 | + | func colorAt(seps []ports.Separator, x, y int) string { | |
| 26 | + | got := "" | |
| 27 | + | for _, s := range seps { | |
| 28 | + | r := s.Rect | |
| 29 | + | if x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H { | |
| 30 | + | got = "gray" | |
| 31 | + | if s.Active { | |
| 32 | + | got = "lit" | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | return got | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | type px struct { | |
| 40 | + | x, y int | |
| 41 | + | want string | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestSeparators(t *testing.T) { | |
| 45 | + | cols2 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}} | |
| 46 | + | cols3 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}, {X: 32, Y: 0, W: 16, H: 16}} | |
| 47 | + | // A full-height column and a column split in two. | |
| 48 | + | grid := []Rect{{X: 0, Y: 0, W: 16, H: 32}, {X: 16, Y: 0, W: 16, H: 16}, {X: 16, Y: 16, W: 16, H: 16}} | |
| 49 | + | for _, tc := range []struct { | |
| 50 | + | name string | |
| 51 | + | ps []Placement | |
| 52 | + | lit bool | |
| 53 | + | check []px | |
| 54 | + | }{ | |
| 55 | + | // The left window owns the line (x 14-15); the client of the right | |
| 56 | + | // window is not covered. | |
| 57 | + | {"two columns, left focused: top half", tiles(0, cols2...), true, []px{{14, 3, "lit"}, {15, 7, "lit"}, {14, 8, "gray"}, {14, 15, "gray"}, {16, 3, ""}, {13, 3, ""}}}, | |
| 58 | + | {"two columns, right focused: bottom half", tiles(1, cols2...), true, []px{{14, 3, "gray"}, {14, 12, "lit"}}}, | |
| 59 | + | {"unfocused output: all gray", tiles(0, cols2...), false, []px{{14, 3, "gray"}, {14, 12, "gray"}}}, | |
| 60 | + | {"three columns, middle: both lines whole", tiles(1, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "lit"}, {30, 12, "lit"}}}, | |
| 61 | + | {"three columns, first: its line only", tiles(0, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "gray"}, {30, 12, "gray"}}}, | |
| 62 | + | {"no line at the screen edge", tiles(2, cols3...), true, []px{{47, 3, ""}, {0, 3, ""}}}, | |
| 63 | + | // The left and top lines meet: the corner square is lit. | |
| 64 | + | {"bottom-right tile: corner closed", tiles(2, grid...), true, []px{{14, 30, "lit"}, {14, 20, "lit"}, {14, 14, "lit"}, {14, 15, "lit"}, {14, 5, "gray"}, {20, 14, "lit"}, {31, 15, "lit"}}}, | |
| 65 | + | {"top-right tile", tiles(1, grid...), true, []px{{14, 3, "lit"}, {14, 15, "lit"}, {14, 20, "gray"}, {20, 14, "lit"}}}, | |
| 66 | + | {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}}, | |
| 67 | + | } { | |
| 68 | + | t.Run(tc.name, func(t *testing.T) { | |
| 69 | + | seps := separators(tc.ps, 2, 0, bigOutput, tc.lit) | |
| 70 | + | for _, c := range tc.check { | |
| 71 | + | if got := colorAt(seps, c.x, c.y); got != c.want { | |
| 72 | + | t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestSeparatorsSkip(t *testing.T) { | |
| 80 | + | if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, bigOutput, true); len(s) != 0 { | |
| 81 | + | t.Fatal("lone window:", s) | |
| 82 | + | } | |
| 83 | + | if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, 0, bigOutput, true); len(s) != 0 { | |
| 84 | + | t.Fatal("width 0:", s) | |
| 85 | + | } | |
| 86 | + | full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}) | |
| 87 | + | full[0].Fullscreen, full[1].Hidden = true, true | |
| 88 | + | if s := separators(full, 2, 0, bigOutput, true); len(s) != 0 { | |
| 89 | + | t.Fatal("fullscreen:", s) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestSeparatorsFloat(t *testing.T) { | |
| 94 | + | float := Placement{ID: 1, Rect: Rect{X: 10, Y: 10, W: 20, H: 20}, Floating: true, Focused: true, Inset: ports.SideAll} | |
| 95 | + | seps := separators([]Placement{float}, 2, 0, bigOutput, true) | |
| 96 | + | if seps[0].Window != 1 { | |
| 97 | + | t.Fatal("float border not tied to its window:", seps) | |
| 98 | + | } | |
| 99 | + | for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} { | |
| 100 | + | if got := colorAt(seps, c[0], c[1]); got != "lit" { | |
| 101 | + | t.Errorf("(%d,%d) = %q", c[0], c[1], got) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | if got := colorAt(seps, 20, 20); got != "" { | |
| 105 | + | t.Error("inside:", got) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Through the real layout: scroll mode keeps client sizes as the view | |
| 110 | + | // moves, and draws no line at the output edge. | |
| 111 | + | func TestSeparatorsScroll(t *testing.T) { | |
| 112 | + | m := monitor() // 100x80, 2 columns on screen | |
| 113 | + | for id := WindowID(1); id <= 3; id++ { | |
| 114 | + | m.AddWindow(id) | |
| 115 | + | } | |
| 116 | + | w := m.Current() | |
| 117 | + | insets := func() map[WindowID]ports.Sides { | |
| 118 | + | got := map[WindowID]ports.Sides{} | |
| 119 | + | for _, p := range w.Layout() { | |
| 120 | + | got[p.ID] = p.Inset | |
| 121 | + | } | |
| 122 | + | return got | |
| 123 | + | } | |
| 124 | + | before := insets() | |
| 125 | + | if w.ViewX == 0 { | |
| 126 | + | t.Fatal("expected a scrolled view") | |
| 127 | + | } | |
| 128 | + | m.Apply(ActionFocusColumnLeft) | |
| 129 | + | m.Apply(ActionFocusColumnLeft) | |
| 130 | + | if after := insets(); !reflect.DeepEqual(before, after) { | |
| 131 | + | t.Fatalf("insets change with the view: %v then %v", before, after) | |
| 132 | + | } | |
| 133 | + | out := Rect{W: 100, H: 80} | |
| 134 | + | seps := separators(w.Layout(), 2, 0, out, true) | |
| 135 | + | if got := colorAt(seps, 99, 40); got != "" { | |
| 136 | + | t.Fatalf("line at the output edge: %v", seps) | |
| 137 | + | } | |
| 138 | + | // Two columns on screen out of three: the half rule applies. | |
| 139 | + | if got := colorAt(seps, 48, 60); got != "gray" { | |
| 140 | + | t.Fatalf("(48,60) = %q; %v", got, seps) | |
| 141 | + | } | |
| 142 | + | if got := colorAt(seps, 48, 10); got != "lit" { | |
| 143 | + | t.Fatalf("(48,10) = %q; %v", got, seps) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // With gaps each tile has its own line: the focused one lights it whole. | |
| 148 | + | func TestSeparatorsGaps(t *testing.T) { | |
| 149 | + | ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}} | |
| 150 | + | setNeighbors(ps, 4) | |
| 151 | + | seps := separators(ps, 2, 4, bigOutput, true) | |
| 152 | + | for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} { | |
| 153 | + | if got := colorAt(seps, c.x, c.y); got != c.want { | |
| 154 | + | t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| @@ -0,0 +1,157 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // tiles lays out rects edge to edge (no gap) with focus on index f. | |||
| 11 | + | func tiles(f int, rs ...Rect) []Placement { | |||
| 12 | + | ps := make([]Placement, len(rs)) | |||
| 13 | + | for i, r := range rs { | |||
| 14 | + | ps[i] = Placement{ID: WindowID(i + 1), Rect: r, Focused: i == f} | |||
| 15 | + | } | |||
| 16 | + | setNeighbors(ps, 0) | |||
| 17 | + | return ps | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // bigOutput is an output wider than every test layout. | |||
| 21 | + | var bigOutput = Rect{W: 1000, H: 1000} | |||
| 22 | + | ||||
| 23 | + | // colorAt is what the renderer shows at (x, y): the last separator over | |||
| 24 | + | // it wins. "" is no line. | |||
| 25 | + | func colorAt(seps []ports.Separator, x, y int) string { | |||
| 26 | + | got := "" | |||
| 27 | + | for _, s := range seps { | |||
| 28 | + | r := s.Rect | |||
| 29 | + | if x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H { | |||
| 30 | + | got = "gray" | |||
| 31 | + | if s.Active { | |||
| 32 | + | got = "lit" | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | return got | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type px struct { | |||
| 40 | + | x, y int | |||
| 41 | + | want string | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestSeparators(t *testing.T) { | |||
| 45 | + | cols2 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}} | |||
| 46 | + | cols3 := []Rect{{X: 0, Y: 0, W: 16, H: 16}, {X: 16, Y: 0, W: 16, H: 16}, {X: 32, Y: 0, W: 16, H: 16}} | |||
| 47 | + | // A full-height column and a column split in two. | |||
| 48 | + | grid := []Rect{{X: 0, Y: 0, W: 16, H: 32}, {X: 16, Y: 0, W: 16, H: 16}, {X: 16, Y: 16, W: 16, H: 16}} | |||
| 49 | + | for _, tc := range []struct { | |||
| 50 | + | name string | |||
| 51 | + | ps []Placement | |||
| 52 | + | lit bool | |||
| 53 | + | check []px | |||
| 54 | + | }{ | |||
| 55 | + | // The left window owns the line (x 14-15); the client of the right | |||
| 56 | + | // window is not covered. | |||
| 57 | + | {"two columns, left focused: top half", tiles(0, cols2...), true, []px{{14, 3, "lit"}, {15, 7, "lit"}, {14, 8, "gray"}, {14, 15, "gray"}, {16, 3, ""}, {13, 3, ""}}}, | |||
| 58 | + | {"two columns, right focused: bottom half", tiles(1, cols2...), true, []px{{14, 3, "gray"}, {14, 12, "lit"}}}, | |||
| 59 | + | {"unfocused output: all gray", tiles(0, cols2...), false, []px{{14, 3, "gray"}, {14, 12, "gray"}}}, | |||
| 60 | + | {"three columns, middle: both lines whole", tiles(1, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "lit"}, {30, 12, "lit"}}}, | |||
| 61 | + | {"three columns, first: its line only", tiles(0, cols3...), true, []px{{14, 3, "lit"}, {14, 12, "lit"}, {30, 3, "gray"}, {30, 12, "gray"}}}, | |||
| 62 | + | {"no line at the screen edge", tiles(2, cols3...), true, []px{{47, 3, ""}, {0, 3, ""}}}, | |||
| 63 | + | // The left and top lines meet: the corner square is lit. | |||
| 64 | + | {"bottom-right tile: corner closed", tiles(2, grid...), true, []px{{14, 30, "lit"}, {14, 20, "lit"}, {14, 14, "lit"}, {14, 15, "lit"}, {14, 5, "gray"}, {20, 14, "lit"}, {31, 15, "lit"}}}, | |||
| 65 | + | {"top-right tile", tiles(1, grid...), true, []px{{14, 3, "lit"}, {14, 15, "lit"}, {14, 20, "gray"}, {20, 14, "lit"}}}, | |||
| 66 | + | {"left column: whole line, split column untouched", tiles(0, grid...), true, []px{{14, 3, "lit"}, {14, 30, "lit"}, {20, 14, "gray"}}}, | |||
| 67 | + | } { | |||
| 68 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 69 | + | seps := separators(tc.ps, 2, 0, bigOutput, tc.lit) | |||
| 70 | + | for _, c := range tc.check { | |||
| 71 | + | if got := colorAt(seps, c.x, c.y); got != c.want { | |||
| 72 | + | t.Errorf("(%d,%d) = %q, want %q; %v", c.x, c.y, got, c.want, seps) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestSeparatorsSkip(t *testing.T) { | |||
| 80 | + | if s := separators(tiles(0, Rect{W: 32, H: 16}), 2, 0, bigOutput, true); len(s) != 0 { | |||
| 81 | + | t.Fatal("lone window:", s) | |||
| 82 | + | } | |||
| 83 | + | if s := separators(tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}), 0, 0, bigOutput, true); len(s) != 0 { | |||
| 84 | + | t.Fatal("width 0:", s) | |||
| 85 | + | } | |||
| 86 | + | full := tiles(0, Rect{W: 16, H: 16}, Rect{X: 16, W: 16, H: 16}) | |||
| 87 | + | full[0].Fullscreen, full[1].Hidden = true, true | |||
| 88 | + | if s := separators(full, 2, 0, bigOutput, true); len(s) != 0 { | |||
| 89 | + | t.Fatal("fullscreen:", s) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestSeparatorsFloat(t *testing.T) { | |||
| 94 | + | float := Placement{ID: 1, Rect: Rect{X: 10, Y: 10, W: 20, H: 20}, Floating: true, Focused: true, Inset: ports.SideAll} | |||
| 95 | + | seps := separators([]Placement{float}, 2, 0, bigOutput, true) | |||
| 96 | + | if seps[0].Window != 1 { | |||
| 97 | + | t.Fatal("float border not tied to its window:", seps) | |||
| 98 | + | } | |||
| 99 | + | for _, c := range [][2]int{{10, 10}, {29, 29}, {20, 11}} { | |||
| 100 | + | if got := colorAt(seps, c[0], c[1]); got != "lit" { | |||
| 101 | + | t.Errorf("(%d,%d) = %q", c[0], c[1], got) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | if got := colorAt(seps, 20, 20); got != "" { | |||
| 105 | + | t.Error("inside:", got) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Through the real layout: scroll mode keeps client sizes as the view | |||
| 110 | + | // moves, and draws no line at the output edge. | |||
| 111 | + | func TestSeparatorsScroll(t *testing.T) { | |||
| 112 | + | m := monitor() // 100x80, 2 columns on screen | |||
| 113 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 114 | + | m.AddWindow(id) | |||
| 115 | + | } | |||
| 116 | + | w := m.Current() | |||
| 117 | + | insets := func() map[WindowID]ports.Sides { | |||
| 118 | + | got := map[WindowID]ports.Sides{} | |||
| 119 | + | for _, p := range w.Layout() { | |||
| 120 | + | got[p.ID] = p.Inset | |||
| 121 | + | } | |||
| 122 | + | return got | |||
| 123 | + | } | |||
| 124 | + | before := insets() | |||
| 125 | + | if w.ViewX == 0 { | |||
| 126 | + | t.Fatal("expected a scrolled view") | |||
| 127 | + | } | |||
| 128 | + | m.Apply(ActionFocusColumnLeft) | |||
| 129 | + | m.Apply(ActionFocusColumnLeft) | |||
| 130 | + | if after := insets(); !reflect.DeepEqual(before, after) { | |||
| 131 | + | t.Fatalf("insets change with the view: %v then %v", before, after) | |||
| 132 | + | } | |||
| 133 | + | out := Rect{W: 100, H: 80} | |||
| 134 | + | seps := separators(w.Layout(), 2, 0, out, true) | |||
| 135 | + | if got := colorAt(seps, 99, 40); got != "" { | |||
| 136 | + | t.Fatalf("line at the output edge: %v", seps) | |||
| 137 | + | } | |||
| 138 | + | // Two columns on screen out of three: the half rule applies. | |||
| 139 | + | if got := colorAt(seps, 48, 60); got != "gray" { | |||
| 140 | + | t.Fatalf("(48,60) = %q; %v", got, seps) | |||
| 141 | + | } | |||
| 142 | + | if got := colorAt(seps, 48, 10); got != "lit" { | |||
| 143 | + | t.Fatalf("(48,10) = %q; %v", got, seps) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // With gaps each tile has its own line: the focused one lights it whole. | |||
| 148 | + | func TestSeparatorsGaps(t *testing.T) { | |||
| 149 | + | ps := []Placement{{ID: 1, Rect: Rect{X: 0, W: 16, H: 16}, Focused: true}, {ID: 2, Rect: Rect{X: 20, W: 16, H: 16}}} | |||
| 150 | + | setNeighbors(ps, 4) | |||
| 151 | + | seps := separators(ps, 2, 4, bigOutput, true) | |||
| 152 | + | for _, c := range []px{{14, 2, "lit"}, {14, 13, "lit"}, {20, 8, "gray"}, {17, 8, ""}} { | |||
| 153 | + | if got := colorAt(seps, c.x, c.y); got != c.want { | |||
| 154 | + | t.Errorf("(%d,%d) = %q, want %q", c.x, c.y, got, c.want) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | } | |||
M internal/ports/messages.go +20 -3
| @@ -591,6 +591,10 @@ type Scene struct { | |||
| 591 | 591 | Background string | |
| 592 | 592 | Border Border | |
| 593 | 593 | Windows []SceneWindow | |
| 594 | + | // Separators are the lines between windows, drawn in slice order with | |
| 595 | + | // the Border colors: tile lines over the tiles, under the floats; a | |
| 596 | + | // float's border right after the float. | |
| 597 | + | Separators []Separator | |
| 594 | 598 | // Layers are drawn in slice order: background and bottom before windows, | |
| 595 | 599 | // top and overlay after. A fullscreen window covers bottom and top. | |
| 596 | 600 | // Window popups are drawn after the windows, layer popups | |
| @@ -614,12 +618,22 @@ func (s Scene) Shows(id WindowID) bool { | |||
| 614 | 618 | return false | |
| 615 | 619 | } | |
| 616 | 620 | ||
| 617 | - | // Border carries the window border style; colors are #rrggbb, "" skips drawing. | |
| 621 | + | // Border carries the window border style; colors are #rrggbb, "" skips | |
| 622 | + | // drawing. Width is in logical pixels. | |
| 618 | 623 | type Border struct { | |
| 619 | 624 | Width int | |
| 620 | 625 | Active, Inactive string | |
| 621 | 626 | } | |
| 622 | 627 | ||
| 628 | + | // Separator is a line between windows, in logical pixels. Active lines | |
| 629 | + | // mark the focused window and use Border.Active, others Border.Inactive. | |
| 630 | + | // Window is the float whose border it is; 0 for lines between tiles. | |
| 631 | + | type Separator struct { | |
| 632 | + | Rect Rect | |
| 633 | + | Active bool | |
| 634 | + | Window WindowID | |
| 635 | + | } | |
| 636 | + | ||
| 623 | 637 | // SceneLayer carries core → renderer layer surface placement. | |
| 624 | 638 | type SceneLayer struct { | |
| 625 | 639 | ID WindowID | |
| @@ -760,8 +774,11 @@ type SceneWindow struct { | |||
| 760 | 774 | ID WindowID | |
| 761 | 775 | Rect Rect | |
| 762 | 776 | Focused, Fullscreen, Hidden bool | |
| 763 | - | // Borderless windows fill the usable width alone; no border is drawn. | |
| 764 | - | Borderless bool | |
| 777 | + | // Inset sides carry a separator line inside Rect: the client is drawn | |
| 778 | + | // inside it. | |
| 779 | + | Inset Sides | |
| 780 | + | // Floating windows are drawn over the tiles and their lines. | |
| 781 | + | Floating bool | |
| 765 | 782 | // Popups are drawn from their content only: no border, no background. | |
| 766 | 783 | Popup bool | |
| 767 | 784 | // OverLayers popups hang from a layer surface: drawn over the top and | |
| @@ -591,6 +591,10 @@ type Scene struct { | |||||
| 591 | Background string | 591 | Background string | ||
| 592 | Border Border | 592 | Border Border | ||
| 593 | Windows []SceneWindow | 593 | Windows []SceneWindow | ||
| 594 | + | // Separators are the lines between windows, drawn in slice order with | |||
| 595 | + | // the Border colors: tile lines over the tiles, under the floats; a | |||
| 596 | + | // float's border right after the float. | |||
| 597 | + | Separators []Separator | |||
| 594 | // Layers are drawn in slice order: background and bottom before windows, | 598 | // Layers are drawn in slice order: background and bottom before windows, | ||
| 595 | // top and overlay after. A fullscreen window covers bottom and top. | 599 | // top and overlay after. A fullscreen window covers bottom and top. | ||
| 596 | // Window popups are drawn after the windows, layer popups | 600 | // Window popups are drawn after the windows, layer popups | ||
| @@ -614,12 +618,22 @@ func (s Scene) Shows(id WindowID) bool { | |||||
| 614 | return false | 618 | return false | ||
| 615 | } | 619 | } | ||
| 616 | 620 | ||||
| 617 | - | // Border carries the window border style; colors are #rrggbb, "" skips drawing. | 621 | + | // Border carries the window border style; colors are #rrggbb, "" skips |
| 622 | + | // drawing. Width is in logical pixels. | |||
| 618 | type Border struct { | 623 | type Border struct { | ||
| 619 | Width int | 624 | Width int | ||
| 620 | Active, Inactive string | 625 | Active, Inactive string | ||
| 621 | } | 626 | } | ||
| 622 | 627 | ||||
| 628 | + | // Separator is a line between windows, in logical pixels. Active lines | |||
| 629 | + | // mark the focused window and use Border.Active, others Border.Inactive. | |||
| 630 | + | // Window is the float whose border it is; 0 for lines between tiles. | |||
| 631 | + | type Separator struct { | |||
| 632 | + | Rect Rect | |||
| 633 | + | Active bool | |||
| 634 | + | Window WindowID | |||
| 635 | + | } | |||
| 636 | + | ||||
| 623 | // SceneLayer carries core → renderer layer surface placement. | 637 | // SceneLayer carries core → renderer layer surface placement. | ||
| 624 | type SceneLayer struct { | 638 | type SceneLayer struct { | ||
| 625 | ID WindowID | 639 | ID WindowID | ||
| @@ -760,8 +774,11 @@ type SceneWindow struct { | |||||
| 760 | ID WindowID | 774 | ID WindowID | ||
| 761 | Rect Rect | 775 | Rect Rect | ||
| 762 | Focused, Fullscreen, Hidden bool | 776 | Focused, Fullscreen, Hidden bool | ||
| 763 | - | // Borderless windows fill the usable width alone; no border is drawn. | 777 | + | // Inset sides carry a separator line inside Rect: the client is drawn |
| 764 | - | Borderless bool | 778 | + | // inside it. |
| 779 | + | Inset Sides | |||
| 780 | + | // Floating windows are drawn over the tiles and their lines. | |||
| 781 | + | Floating bool | |||
| 765 | // Popups are drawn from their content only: no border, no background. | 782 | // Popups are drawn from their content only: no border, no background. | ||
| 766 | Popup bool | 783 | Popup bool | ||
| 767 | // OverLayers popups hang from a layer surface: drawn over the top and | 784 | // 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 | |||
| 5 | 5 | ||
| 6 | 6 | // Rect is a pixel rectangle. | |
| 7 | 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 | + | } | |
| @@ -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 | + | } | |||