feat(window): add tiling, snapping, stacking, and workflow layouts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ff44b8c654
commit
060bbe7c0b
2 changed files with 305 additions and 2 deletions
241
pkg/window/tiling.go
Normal file
241
pkg/window/tiling.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// pkg/window/tiling.go
|
||||
package window
|
||||
|
||||
import "fmt"
|
||||
|
||||
// TileMode defines how windows are arranged.
|
||||
type TileMode int
|
||||
|
||||
const (
|
||||
TileModeLeftHalf TileMode = iota
|
||||
TileModeRightHalf
|
||||
TileModeTopHalf
|
||||
TileModeBottomHalf
|
||||
TileModeTopLeft
|
||||
TileModeTopRight
|
||||
TileModeBottomLeft
|
||||
TileModeBottomRight
|
||||
TileModeLeftRight
|
||||
TileModeGrid
|
||||
)
|
||||
|
||||
var tileModeNames = map[TileMode]string{
|
||||
TileModeLeftHalf: "left-half", TileModeRightHalf: "right-half",
|
||||
TileModeTopHalf: "top-half", TileModeBottomHalf: "bottom-half",
|
||||
TileModeTopLeft: "top-left", TileModeTopRight: "top-right",
|
||||
TileModeBottomLeft: "bottom-left", TileModeBottomRight: "bottom-right",
|
||||
TileModeLeftRight: "left-right", TileModeGrid: "grid",
|
||||
}
|
||||
|
||||
func (m TileMode) String() string { return tileModeNames[m] }
|
||||
|
||||
// SnapPosition defines where a window snaps to.
|
||||
type SnapPosition int
|
||||
|
||||
const (
|
||||
SnapLeft SnapPosition = iota
|
||||
SnapRight
|
||||
SnapTop
|
||||
SnapBottom
|
||||
SnapTopLeft
|
||||
SnapTopRight
|
||||
SnapBottomLeft
|
||||
SnapBottomRight
|
||||
SnapCenter
|
||||
)
|
||||
|
||||
// WorkflowLayout is a predefined arrangement for common tasks.
|
||||
type WorkflowLayout int
|
||||
|
||||
const (
|
||||
WorkflowCoding WorkflowLayout = iota // 70/30 split
|
||||
WorkflowDebugging // 60/40 split
|
||||
WorkflowPresenting // maximised
|
||||
WorkflowSideBySide // 50/50 split
|
||||
)
|
||||
|
||||
var workflowNames = map[WorkflowLayout]string{
|
||||
WorkflowCoding: "coding", WorkflowDebugging: "debugging",
|
||||
WorkflowPresenting: "presenting", WorkflowSideBySide: "side-by-side",
|
||||
}
|
||||
|
||||
func (w WorkflowLayout) String() string { return workflowNames[w] }
|
||||
|
||||
// TileWindows arranges the named windows in the given mode across the screen area.
|
||||
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int) error {
|
||||
windows := make([]PlatformWindow, 0, len(names))
|
||||
for _, name := range names {
|
||||
pw, ok := m.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window %q not found", name)
|
||||
}
|
||||
windows = append(windows, pw)
|
||||
}
|
||||
if len(windows) == 0 {
|
||||
return fmt.Errorf("no windows to tile")
|
||||
}
|
||||
|
||||
halfW, halfH := screenW/2, screenH/2
|
||||
|
||||
switch mode {
|
||||
case TileModeLeftRight:
|
||||
w := screenW / len(windows)
|
||||
for i, pw := range windows {
|
||||
pw.SetPosition(i*w, 0)
|
||||
pw.SetSize(w, screenH)
|
||||
}
|
||||
case TileModeGrid:
|
||||
cols := 2
|
||||
if len(windows) > 4 {
|
||||
cols = 3
|
||||
}
|
||||
cellW := screenW / cols
|
||||
for i, pw := range windows {
|
||||
row := i / cols
|
||||
col := i % cols
|
||||
rows := (len(windows) + cols - 1) / cols
|
||||
cellH := screenH / rows
|
||||
pw.SetPosition(col*cellW, row*cellH)
|
||||
pw.SetSize(cellW, cellH)
|
||||
}
|
||||
case TileModeLeftHalf:
|
||||
for _, pw := range windows {
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetSize(halfW, screenH)
|
||||
}
|
||||
case TileModeRightHalf:
|
||||
for _, pw := range windows {
|
||||
pw.SetPosition(halfW, 0)
|
||||
pw.SetSize(halfW, screenH)
|
||||
}
|
||||
case TileModeTopHalf:
|
||||
for _, pw := range windows {
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetSize(screenW, halfH)
|
||||
}
|
||||
case TileModeBottomHalf:
|
||||
for _, pw := range windows {
|
||||
pw.SetPosition(0, halfH)
|
||||
pw.SetSize(screenW, halfH)
|
||||
}
|
||||
case TileModeTopLeft:
|
||||
for _, pw := range windows {
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetSize(halfW, halfH)
|
||||
}
|
||||
case TileModeTopRight:
|
||||
for _, pw := range windows {
|
||||
pw.SetPosition(halfW, 0)
|
||||
pw.SetSize(halfW, halfH)
|
||||
}
|
||||
case TileModeBottomLeft:
|
||||
for _, pw := range windows {
|
||||
pw.SetPosition(0, halfH)
|
||||
pw.SetSize(halfW, halfH)
|
||||
}
|
||||
case TileModeBottomRight:
|
||||
for _, pw := range windows {
|
||||
pw.SetPosition(halfW, halfH)
|
||||
pw.SetSize(halfW, halfH)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SnapWindow snaps a window to a screen edge/corner/centre.
|
||||
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error {
|
||||
pw, ok := m.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window %q not found", name)
|
||||
}
|
||||
|
||||
halfW, halfH := screenW/2, screenH/2
|
||||
|
||||
switch pos {
|
||||
case SnapLeft:
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetSize(halfW, screenH)
|
||||
case SnapRight:
|
||||
pw.SetPosition(halfW, 0)
|
||||
pw.SetSize(halfW, screenH)
|
||||
case SnapTop:
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetSize(screenW, halfH)
|
||||
case SnapBottom:
|
||||
pw.SetPosition(0, halfH)
|
||||
pw.SetSize(screenW, halfH)
|
||||
case SnapTopLeft:
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetSize(halfW, halfH)
|
||||
case SnapTopRight:
|
||||
pw.SetPosition(halfW, 0)
|
||||
pw.SetSize(halfW, halfH)
|
||||
case SnapBottomLeft:
|
||||
pw.SetPosition(0, halfH)
|
||||
pw.SetSize(halfW, halfH)
|
||||
case SnapBottomRight:
|
||||
pw.SetPosition(halfW, halfH)
|
||||
pw.SetSize(halfW, halfH)
|
||||
case SnapCenter:
|
||||
cw, ch := pw.Size()
|
||||
pw.SetPosition((screenW-cw)/2, (screenH-ch)/2)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StackWindows cascades windows with an offset.
|
||||
func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error {
|
||||
for i, name := range names {
|
||||
pw, ok := m.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window %q not found", name)
|
||||
}
|
||||
pw.SetPosition(i*offsetX, i*offsetY)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyWorkflow arranges windows in a predefined workflow layout.
|
||||
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error {
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("no windows for workflow")
|
||||
}
|
||||
|
||||
switch workflow {
|
||||
case WorkflowCoding:
|
||||
// 70/30 split — main editor + terminal
|
||||
mainW := screenW * 70 / 100
|
||||
if pw, ok := m.Get(names[0]); ok {
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetSize(mainW, screenH)
|
||||
}
|
||||
if len(names) > 1 {
|
||||
if pw, ok := m.Get(names[1]); ok {
|
||||
pw.SetPosition(mainW, 0)
|
||||
pw.SetSize(screenW-mainW, screenH)
|
||||
}
|
||||
}
|
||||
case WorkflowDebugging:
|
||||
// 60/40 split
|
||||
mainW := screenW * 60 / 100
|
||||
if pw, ok := m.Get(names[0]); ok {
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetSize(mainW, screenH)
|
||||
}
|
||||
if len(names) > 1 {
|
||||
if pw, ok := m.Get(names[1]); ok {
|
||||
pw.SetPosition(mainW, 0)
|
||||
pw.SetSize(screenW-mainW, screenH)
|
||||
}
|
||||
}
|
||||
case WorkflowPresenting:
|
||||
// Maximise first window
|
||||
if pw, ok := m.Get(names[0]); ok {
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetSize(screenW, screenH)
|
||||
}
|
||||
case WorkflowSideBySide:
|
||||
return m.TileWindows(TileModeLeftRight, names, screenW, screenH)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -79,10 +79,16 @@ func TestApplyOptions_Empty_Good(t *testing.T) {
|
|||
assert.NotNil(t, w)
|
||||
}
|
||||
|
||||
// newTestManager creates a Manager with a mock platform for testing.
|
||||
// newTestManager creates a Manager with a mock platform and clean state for testing.
|
||||
func newTestManager() (*Manager, *mockPlatform) {
|
||||
p := newMockPlatform()
|
||||
return NewManager(p), p
|
||||
m := &Manager{
|
||||
platform: p,
|
||||
state: &StateManager{states: make(map[string]WindowState)},
|
||||
layout: &LayoutManager{layouts: make(map[string]Layout)},
|
||||
windows: make(map[string]PlatformWindow),
|
||||
}
|
||||
return m, p
|
||||
}
|
||||
|
||||
func TestManager_Open_Good(t *testing.T) {
|
||||
|
|
@ -266,3 +272,59 @@ func TestLayoutManager_DeleteLayout_Good(t *testing.T) {
|
|||
_, ok := lm.GetLayout("temp")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// --- Tiling Tests ---
|
||||
|
||||
func TestTileMode_String_Good(t *testing.T) {
|
||||
assert.Equal(t, "left-half", TileModeLeftHalf.String())
|
||||
assert.Equal(t, "grid", TileModeGrid.String())
|
||||
}
|
||||
|
||||
func TestManager_TileWindows_Good(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
_, _ = m.Open(WithName("a"), WithSize(800, 600))
|
||||
_, _ = m.Open(WithName("b"), WithSize(800, 600))
|
||||
err := m.TileWindows(TileModeLeftRight, []string{"a", "b"}, 1920, 1080)
|
||||
require.NoError(t, err)
|
||||
a, _ := m.Get("a")
|
||||
b, _ := m.Get("b")
|
||||
aw, _ := a.Size()
|
||||
bw, _ := b.Size()
|
||||
assert.Equal(t, 960, aw)
|
||||
assert.Equal(t, 960, bw)
|
||||
}
|
||||
|
||||
func TestManager_TileWindows_Bad(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
err := m.TileWindows(TileModeLeftRight, []string{"nonexistent"}, 1920, 1080)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestManager_SnapWindow_Good(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
_, _ = m.Open(WithName("snap"), WithSize(800, 600))
|
||||
err := m.SnapWindow("snap", SnapLeft, 1920, 1080)
|
||||
require.NoError(t, err)
|
||||
w, _ := m.Get("snap")
|
||||
x, _ := w.Position()
|
||||
assert.Equal(t, 0, x)
|
||||
sw, _ := w.Size()
|
||||
assert.Equal(t, 960, sw)
|
||||
}
|
||||
|
||||
func TestManager_StackWindows_Good(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
_, _ = m.Open(WithName("s1"), WithSize(800, 600))
|
||||
_, _ = m.Open(WithName("s2"), WithSize(800, 600))
|
||||
err := m.StackWindows([]string{"s1", "s2"}, 30, 30)
|
||||
require.NoError(t, err)
|
||||
s2, _ := m.Get("s2")
|
||||
x, y := s2.Position()
|
||||
assert.Equal(t, 30, x)
|
||||
assert.Equal(t, 30, y)
|
||||
}
|
||||
|
||||
func TestWorkflowLayout_Good(t *testing.T) {
|
||||
assert.Equal(t, "coding", WorkflowCoding.String())
|
||||
assert.Equal(t, "debugging", WorkflowDebugging.String())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue