From b7f025377881626ccfbc14fadd94c7ae13de8398 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 17 Oct 2026 21:54:35 -0300 Subject: [PATCH 1/2] feat(native): split layouts as window tabs with a free pane tree Layouts are tabs in the topbar strip, after the projects, and can mix projects. Each one is a binary split tree built by dragging a card or a pane header onto a pane's edges, or from the keyboard; the focused pane can be zoomed, rotated or unsplit, and a layout left with one pane closes. Layouts persist under lich.native.layouts, apart from the web's walls. --- CHANGELOG.md | 13 + docs/ceilings.md | 19 + native/cmd/lich-native/hotkeys.go | 8 + native/cmd/lich-native/hotkeys_dispatch.go | 17 +- native/cmd/lich-native/layouts.go | 342 +++++++++++++ native/cmd/lich-native/layouts_drag.go | 336 +++++++++++++ native/cmd/lich-native/layouts_drag_test.go | 104 ++++ native/cmd/lich-native/layouts_test.go | 225 +++++++++ native/cmd/lich-native/layouts_window.go | 111 +++++ native/cmd/lich-native/main.go | 86 ++-- native/cmd/lich-native/model.go | 27 ++ native/cmd/lich-native/model_layouts.go | 465 ++++++++++++++++++ native/cmd/lich-native/model_layouts_test.go | 326 +++++++++++++ native/cmd/lich-native/palette.go | 19 +- native/cmd/lich-native/palette_window.go | 17 +- native/cmd/lich-native/projects.go | 4 +- native/cmd/lich-native/sidebar_block.go | 5 +- native/cmd/lich-native/sidebar_card.go | 39 ++ native/cmd/lich-native/stage_layout.go | 475 +++++++++++++++++++ native/cmd/lich-native/terminals.go | 85 +++- native/cmd/lich-native/topbar.go | 9 +- native/cmd/lich-native/topbar_apply.go | 25 +- native/cmd/lich-native/topbar_tabs.go | 90 +++- native/i18n/locales/en/hotkeys.json | 4 + native/i18n/locales/en/layouts.json | 36 ++ native/i18n/locales/es/hotkeys.json | 4 + native/i18n/locales/es/layouts.json | 36 ++ native/i18n/locales/ja/hotkeys.json | 4 + native/i18n/locales/ja/layouts.json | 34 ++ native/i18n/locales/ko/hotkeys.json | 4 + native/i18n/locales/ko/layouts.json | 34 ++ native/i18n/locales/pt-br/hotkeys.json | 4 + native/i18n/locales/pt-br/layouts.json | 36 ++ native/i18n/locales/ru/hotkeys.json | 4 + native/i18n/locales/ru/layouts.json | 40 ++ native/i18n/locales/zh-cn/hotkeys.json | 4 + native/i18n/locales/zh-cn/layouts.json | 34 ++ native/lichclient/api.go | 8 +- native/lichclient/client_test.go | 13 + native/ui/sortable.go | 11 +- native/ui/sortable_list.go | 51 +- native/ui/sortable_list_test.go | 74 ++- 42 files changed, 3195 insertions(+), 87 deletions(-) create mode 100644 native/cmd/lich-native/layouts.go create mode 100644 native/cmd/lich-native/layouts_drag.go create mode 100644 native/cmd/lich-native/layouts_drag_test.go create mode 100644 native/cmd/lich-native/layouts_test.go create mode 100644 native/cmd/lich-native/layouts_window.go create mode 100644 native/cmd/lich-native/model_layouts.go create mode 100644 native/cmd/lich-native/model_layouts_test.go create mode 100644 native/cmd/lich-native/stage_layout.go create mode 100644 native/i18n/locales/en/layouts.json create mode 100644 native/i18n/locales/es/layouts.json create mode 100644 native/i18n/locales/ja/layouts.json create mode 100644 native/i18n/locales/ko/layouts.json create mode 100644 native/i18n/locales/pt-br/layouts.json create mode 100644 native/i18n/locales/ru/layouts.json create mode 100644 native/i18n/locales/zh-cn/layouts.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d284c6c..e0eabc73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Split layouts in the native window (experimental).** Put sessions from + any open project side by side and split them however you like: Ctrl+Shift+G + splits the focused session to the right, Ctrl+Shift+I below, each asking + which session goes there, or drag a card from the sidebar onto a pane's + edge. Rearrange a layout by dragging a pane by its header onto another + pane's edge, or onto its middle to swap the two, and turn a pair between + side by side and stacked with Ctrl+Shift+R. Every layout is a tab after the + projects, with the status of its panes, renamed by double-clicking it; drag + the gap between two panes to resize them, zoom one with Ctrl+Shift+M, move + between them with Ctrl+Shift+F, and take one out with Ctrl+Shift+W; a + layout down to one pane closes. Rest a dragged session on another tab and + that tab comes up, as a browser does with a dragged file. The sidebar and + the footer follow the pane in focus. - **Required checks are labelled.** In the native window, a pull request's Checks tab marks the checks that gate its merge with Required, as GitHub does. diff --git a/docs/ceilings.md b/docs/ceilings.md index 2e22e06e..69b8e10e 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -259,6 +259,25 @@ work when nobody knows it and that the call site never shows. The mechanism and project's sessions drop out of every wall, and a wall left with one live member is not drawn. "Show beside its N delegates" still reads the routed project only (`delegatesOf`), so a delegate opened in another project is left out of the wall it builds. +- **The native window's layouts are not the web's walls** (`native/cmd/lich-native/layouts.go`, + `model_layouts.go`, `stage_layout.go`): a layout is a free split tree stored under `lich.native.layouts`, + and neither window reads the other's. A session sits in one pane of one layout, because its PTY has one + size; clicking its card anywhere shows it in that layout, while its project's tab shows it alone. A split + is refused, with a toast, when a pane would drop under 480dp (51 columns at the default 13sp, measured with + Claude Code and Codex): a larger terminal font fits fewer columns in the same floor. Only the focused pane + draws its exit band and takes a dropped file; a pane in the background whose process ended keeps its last + frame until focused, and a file dropped on it goes nowhere. A pinned card cannot be dragged onto a pane: the + pinned block shares no carry with the panes, so its card never leaves the sidebar. The panes' drop zones are + not SortCarry targets: Gio's transfer delivered Enter to targets laid out mid-drag only some of the time, so + the window reads the pointer from a handler over the whole window and works the zone out from where the + panes were drawn (`layouts_drag.go`). A new drop target over the stage belongs there, not in the carry. + Resting a drag on a strip tab switches to it, which can take the drag's own list off screen; Gio drops a + handler nobody asked about in a frame and then dereferences the forgotten source on the next move, so the + window calls `SortCarry.Hold` every frame to keep the source asked about, and lets a drag whose list never + heard the release go (`endStrandedDrag`). The layout hotkeys the web window has no + action for (split below, zoom, take out of the layout, rotate) live in the shared `hotkeys.bindings` value, + which the web window rewrites with its own actions only: rebinding anything there drops a custom chord set + for those four in the native window. - **A theme switch reaches only the apps that asked, and only since the page loaded** (`frontend/src/lib/terminal/theme-notify.ts`): lich answers mode 2031 and sends the light or dark report on a switch, but the "asked" flag lives on the page's terminal entry. A full page reload forgets it, so a session diff --git a/native/cmd/lich-native/hotkeys.go b/native/cmd/lich-native/hotkeys.go index 7897fbba..0ee5cdda 100644 --- a/native/cmd/lich-native/hotkeys.go +++ b/native/cmd/lich-native/hotkeys.go @@ -41,6 +41,10 @@ const ( hotkeySearchInFiles hotkeyID = "searchInFiles" hotkeySplitBeside hotkeyID = "splitBeside" hotkeyOtherPane hotkeyID = "otherPane" + hotkeySplitBelow hotkeyID = "splitBelow" + hotkeyZoomPane hotkeyID = "zoomPane" + hotkeyUnsplitPane hotkeyID = "unsplitPane" + hotkeyRotateSplit hotkeyID = "rotateSplit" hotkeySettings hotkeyID = "settings" hotkeyPulls hotkeyID = "pulls" hotkeyShortcuts hotkeyID = "shortcuts" @@ -115,7 +119,11 @@ var hotkeyActions = labelActions([]hotkeyAction{ {id: hotkeyToggleDock, labelKey: "hotkeys.action.toggleDock", group: hotkeyGroupView, combo: shiftChord("d")}, {id: hotkeySearchInFiles, labelKey: "hotkeys.action.searchInFiles", group: hotkeyGroupView, combo: comboUnassigned}, {id: hotkeySplitBeside, labelKey: "hotkeys.action.splitBeside", group: hotkeyGroupView, combo: shiftChord("g")}, + {id: hotkeySplitBelow, labelKey: "hotkeys.action.splitBelow", group: hotkeyGroupView, combo: shiftChord("i")}, {id: hotkeyOtherPane, labelKey: "hotkeys.action.otherPane", group: hotkeyGroupView, combo: shiftChord("f")}, + {id: hotkeyZoomPane, labelKey: "hotkeys.action.zoomPane", group: hotkeyGroupView, combo: shiftChord("m")}, + {id: hotkeyUnsplitPane, labelKey: "hotkeys.action.unsplitPane", group: hotkeyGroupView, combo: shiftChord("w")}, + {id: hotkeyRotateSplit, labelKey: "hotkeys.action.rotateSplit", group: hotkeyGroupView, combo: shiftChord("r")}, {id: hotkeyCommandPalette, labelKey: "hotkeys.action.commandPalette", group: hotkeyGroupApp, combo: combo{Mod: true, Key: "k"}}, {id: hotkeySettings, labelKey: "hotkeys.action.settings", group: hotkeyGroupApp, combo: combo{Mod: true, Key: ","}}, {id: hotkeyPulls, labelKey: "hotkeys.action.pulls", group: hotkeyGroupApp, combo: shiftChord("p")}, diff --git a/native/cmd/lich-native/hotkeys_dispatch.go b/native/cmd/lich-native/hotkeys_dispatch.go index 72aed236..96ffac73 100644 --- a/native/cmd/lich-native/hotkeys_dispatch.go +++ b/native/cmd/lich-native/hotkeys_dispatch.go @@ -23,9 +23,14 @@ type hotkeyHandler struct { } // hotkeyHandlers are the actions with a native screen or command. Absent, -// and so inert: delegate (no delegate flow), -// splitBeside and otherPane (no split panes). +// and so inert: delegate (no delegate flow). var hotkeyHandlers = map[hotkeyID]hotkeyHandler{ + hotkeySplitBeside: {(*window).hasActiveSession, func(u *window, _ C) { u.pickBeside(edgeRight) }}, + hotkeySplitBelow: {(*window).hasActiveSession, func(u *window, _ C) { u.pickBeside(edgeBottom) }}, + hotkeyOtherPane: {(*window).hasLayout, func(u *window, _ C) { u.model.cyclePane(u.ctx, 1); u.model.invalidate() }}, + hotkeyZoomPane: {(*window).hasLayout, func(u *window, _ C) { u.model.toggleZoom(); u.model.invalidate() }}, + hotkeyUnsplitPane: {(*window).hasLayout, (*window).hotkeyUnsplit}, + hotkeyRotateSplit: {(*window).hasLayout, func(u *window, _ C) { u.model.rotateFocused(); u.model.invalidate() }}, hotkeyNewSession: {(*window).canNewSession, (*window).hotkeyNewSession}, hotkeyNewWorktree: {(*window).hasProject, func(u *window, _ C) { u.showWorktreeDialog() }}, hotkeyRenameSession: {(*window).hasActiveSession, (*window).hotkeyRename}, @@ -230,10 +235,10 @@ func (u *window) hotkeyFocusTerminal(gtx C) { gtx.Execute(key.FocusCmd{}) } -// neighborTab is the project the step lands on in the strip's order, Home -// first, and false when the strip has nowhere to go. "" is Home. +// neighborTab is the project or layout the step lands on in the strip's +// order, Home first, and false when the strip has nowhere to go. "" is Home. func (u *window) neighborTab(step int) (string, bool) { - tabs, active := u.model.projectTabs() + tabs, active := u.model.stripTabs() ids := make([]string, 0, len(tabs)+1) ids = append(ids, "") for _, t := range tabs { @@ -256,7 +261,7 @@ func (u *window) hotkeyTab(step int) { u.saveScreen("") return } - u.showProject(target) + u.showStripTab(target) } // setSidebarOpen is the sidebar's own toggle, stored and morphed to. diff --git a/native/cmd/lich-native/layouts.go b/native/cmd/lich-native/layouts.go new file mode 100644 index 00000000..9f128033 --- /dev/null +++ b/native/cmd/lich-native/layouts.go @@ -0,0 +1,342 @@ +package main + +import ( + "encoding/json" + "fmt" + "slices" +) + +// A layout is a tab of panes the user splits as they like: a binary tree +// whose leaves are sessions, from any open project. It belongs to the window, +// not to a project, and a session sits in at most one pane of one layout, +// since its PTY has one size. + +// layoutsKey is the backend setting the layouts persist under. The web's +// walls live under lich.panes in a shape of their own; the two never read +// each other. +const layoutsKey = "lich.native.layouts" + +// splitAxis is how a node divides its area: side by side or stacked. +type splitAxis string + +const ( + splitRow splitAxis = "row" + splitColumn splitAxis = "column" +) + +// paneEdge is where a session lands against a pane: one of its four edges, +// or its middle, which replaces the session there. +type paneEdge int + +const ( + edgeLeft paneEdge = iota + edgeRight + edgeTop + edgeBottom + edgeCenter +) + +// The ratio a divider can be dragged to; the pixel floor is the stage's +// (paneMinWidth), this only keeps a stored ratio from collapsing a side. +const ( + ratioMin = 0.05 + ratioMax = 0.95 +) + +// paneNode is a leaf when Session is set, else a split of First and Second +// along Axis, First taking Ratio of the area. +type paneNode struct { + Session string `json:"session,omitempty"` + Axis splitAxis `json:"axis,omitempty"` + Ratio float64 `json:"ratio,omitempty"` + First *paneNode `json:"first,omitempty"` + Second *paneNode `json:"second,omitempty"` +} + +func (n *paneNode) leaf() bool { return n.Session != "" } + +// layoutTab is one layout: its tree, the session it last had in focus and +// whether that pane is zoomed over the others. +type layoutTab struct { + ID string `json:"id"` + Name string `json:"name"` + Root *paneNode `json:"root"` + Focus string `json:"focus"` + Zoomed bool `json:"zoomed,omitempty"` +} + +// sessions are the tab's sessions in reading order, left to right and top to +// bottom. +func (t *layoutTab) sessions() []string { + var out []string + var walk func(n *paneNode) + walk = func(n *paneNode) { + if n.leaf() { + out = append(out, n.Session) + return + } + walk(n.First) + walk(n.Second) + } + walk(t.Root) + return out +} + +func (t *layoutTab) holds(id string) bool { return slices.Contains(t.sessions(), id) } + +// split puts session against target's edge, halving target's pane; the +// middle replaces target instead. It reports false when target is not in the +// tab. +func (t *layoutTab) split(target, session string, edge paneEdge) bool { + n := findLeaf(t.Root, target) + if n == nil { + return false + } + if edge == edgeCenter { + n.Session = session + return true + } + old, added := &paneNode{Session: target}, &paneNode{Session: session} + axis := splitRow + if edge == edgeTop || edge == edgeBottom { + axis = splitColumn + } + *n = paneNode{Axis: axis, Ratio: 0.5, First: old, Second: added} + if edge == edgeLeft || edge == edgeTop { + n.First, n.Second = added, old + } + return true +} + +// remove takes session's pane out, its sibling taking the parent's area. It +// reports whether the tab still holds any pane. +func (t *layoutTab) remove(session string) bool { + if t.Root.leaf() { + if t.Root.Session == session { + t.Root = nil + return false + } + return true + } + var walk func(n *paneNode) bool + walk = func(n *paneNode) bool { + if n.leaf() { + return false + } + for _, pair := range [][2]*paneNode{{n.First, n.Second}, {n.Second, n.First}} { + if pair[0].leaf() && pair[0].Session == session { + *n = *pair[1] + return true + } + } + return walk(n.First) || walk(n.Second) + } + walk(t.Root) + if t.Focus == session { + t.Focus = t.sessions()[0] + } + return true +} + +// move puts source's pane against target's edge, taking it out of where it +// was; the middle swaps the two. It reports false when either is not in the +// tab or they are the same pane. +func (t *layoutTab) move(source, target string, edge paneEdge) bool { + if source == target || findLeaf(t.Root, source) == nil || findLeaf(t.Root, target) == nil { + return false + } + if edge == edgeCenter { + findLeaf(t.Root, source).Session, findLeaf(t.Root, target).Session = "\x00swap", source + findLeaf(t.Root, "\x00swap").Session = target + return true + } + t.remove(source) + t.split(target, source, edge) + t.Focus = source + return true +} + +// rotate turns the split holding session's pane from side by side to stacked +// or back, and reports false for a pane alone in the tab. +func (t *layoutTab) rotate(session string) bool { + parent := parentOf(t.Root, session) + if parent == nil { + return false + } + if parent.Axis == splitRow { + parent.Axis = splitColumn + } else { + parent.Axis = splitRow + } + return true +} + +// parentOf is the split whose side is session's leaf, nil for none. +func parentOf(n *paneNode, session string) *paneNode { + if n == nil || n.leaf() { + return nil + } + for _, side := range []*paneNode{n.First, n.Second} { + if side.leaf() && side.Session == session { + return n + } + } + if p := parentOf(n.First, session); p != nil { + return p + } + return parentOf(n.Second, session) +} + +func findLeaf(n *paneNode, session string) *paneNode { + if n == nil { + return nil + } + if n.leaf() { + if n.Session == session { + return n + } + return nil + } + if f := findLeaf(n.First, session); f != nil { + return f + } + return findLeaf(n.Second, session) +} + +// minSize is the smallest area the tree fits in, given the smallest pane and +// the gap between two panes. +func minSize(n *paneNode, paneW, paneH, gap int) (w, h int) { + if n.leaf() { + return paneW, paneH + } + fw, fh := minSize(n.First, paneW, paneH, gap) + sw, sh := minSize(n.Second, paneW, paneH, gap) + if n.Axis == splitRow { + return fw + gap + sw, max(fh, sh) + } + return max(fw, sw), fh + gap + sh +} + +func clampRatio(r float64) float64 { return min(ratioMax, max(ratioMin, r)) } + +// layoutTabs is every layout of the window, in strip order. +type layoutTabs struct { + tabs []layoutTab + // seq numbers the ids handed out; restored past the highest stored. + seq int +} + +// of is the index of the tab holding session, -1 for none. +func (ls *layoutTabs) of(session string) int { + return slices.IndexFunc(ls.tabs, func(t layoutTab) bool { return t.holds(session) }) +} + +func (ls *layoutTabs) byID(id string) *layoutTab { + if i := slices.IndexFunc(ls.tabs, func(t layoutTab) bool { return t.ID == id }); i >= 0 { + return &ls.tabs[i] + } + return nil +} + +// open makes a layout of first and second, second against first's edge, +// named name, and answers its id. Neither may be in a layout already: moving +// one out is the caller's to ask about. +func (ls *layoutTabs) open(name, first, second string, edge paneEdge) string { + ls.seq++ + t := layoutTab{ID: fmt.Sprintf("layout-%d", ls.seq), Name: name, Root: &paneNode{Session: first}, Focus: second} + t.split(first, second, edge) + ls.tabs = append(ls.tabs, t) + return t.ID +} + +// take removes session from whichever layout holds it, reporting whether +// one did. A layout left with a single pane is no layout any more: it goes, +// and left is the session that pane showed, "" while the layout stays. +func (ls *layoutTabs) take(session string) (left string, ok bool) { + i := ls.of(session) + if i < 0 { + return "", false + } + t := &ls.tabs[i] + t.remove(session) + if t.Root.leaf() { + left = t.Root.Session + ls.tabs = slices.Delete(ls.tabs, i, i+1) + } + return left, true +} + +func (ls *layoutTabs) close(id string) { + ls.tabs = slices.DeleteFunc(ls.tabs, func(t layoutTab) bool { return t.ID == id }) +} + +// prune drops every pane whose session is not live, which is how a session +// closed while the window was away leaves the layouts it was in. +func (ls *layoutTabs) prune(live func(id string) bool) bool { + changed := false + for _, t := range slices.Clone(ls.tabs) { + for _, id := range t.sessions() { + if !live(id) { + _, took := ls.take(id) + changed = took || changed + } + } + } + return changed +} + +func (ls *layoutTabs) marshal() (string, error) { + b, err := json.Marshal(ls.tabs) + return string(b), err +} + +// parseLayouts reads the stored layouts. A session in two panes keeps the +// first; a tree with a broken node is an error, since repairing it would +// guess which half the user meant. +func parseLayouts(raw string) (layoutTabs, error) { + var ls layoutTabs + if raw == "" { + return ls, nil + } + if err := json.Unmarshal([]byte(raw), &ls.tabs); err != nil { + return layoutTabs{}, fmt.Errorf("%s: %w", layoutsKey, err) + } + seen := map[string]bool{} + for i := range ls.tabs { + t := &ls.tabs[i] + if err := validNode(t.Root); err != nil { + return layoutTabs{}, fmt.Errorf("%s: layout %q: %w", layoutsKey, t.ID, err) + } + var n int + if _, err := fmt.Sscanf(t.ID, "layout-%d", &n); err == nil { + ls.seq = max(ls.seq, n) + } + for _, id := range t.sessions() { + if seen[id] && !t.remove(id) { + break + } + seen[id] = true + } + if t.Root != nil && !t.holds(t.Focus) { + t.Focus = t.sessions()[0] + } + } + ls.tabs = slices.DeleteFunc(ls.tabs, func(t layoutTab) bool { return t.Root == nil || t.Root.leaf() }) + return ls, nil +} + +func validNode(n *paneNode) error { + switch { + case n == nil: + return fmt.Errorf("missing node") + case n.leaf(): + return nil + case n.Axis != splitRow && n.Axis != splitColumn: + return fmt.Errorf("split axis %q, want row or column", n.Axis) + } + n.Ratio = clampRatio(n.Ratio) + if err := validNode(n.First); err != nil { + return err + } + return validNode(n.Second) +} diff --git a/native/cmd/lich-native/layouts_drag.go b/native/cmd/lich-native/layouts_drag.go new file mode 100644 index 00000000..2d6031aa --- /dev/null +++ b/native/cmd/lich-native/layouts_drag.go @@ -0,0 +1,336 @@ +package main + +import ( + "image" + "strconv" + "strings" + "time" + + "gioui.org/font" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" +) + +// Rearranging a layout by dragging: a pane by its header, or a card from the +// sidebar, onto a pane's edge or middle. Both ride the sidebar's SortCarry, +// whose targets here are each pane's five drop zones. + +// paneTargetPrefix starts a drop zone's carry target id: pane||. +const paneTargetPrefix = "pane|" + +// dropZoneShare is how much of a pane's width or height each edge zone +// takes; the middle keeps the rest. +const dropZoneShare = 4 + +var dropLabel = unit.Dp(6) + +func paneTargetID(session string, edge paneEdge) string { + return paneTargetPrefix + session + "|" + strconv.Itoa(int(edge)) +} + +// parsePaneTarget reads a drop zone's id, false for any other target. +func parsePaneTarget(id string) (session string, edge paneEdge, ok bool) { + rest, found := strings.CutPrefix(id, paneTargetPrefix) + if !found { + return "", 0, false + } + at := strings.LastIndexByte(rest, '|') + if at <= 0 { + return "", 0, false + } + n, err := strconv.Atoi(rest[at+1:]) + if err != nil || n < int(edgeLeft) || n > int(edgeCenter) { + return "", 0, false + } + return rest[:at], paneEdge(n), true +} + +// paneZones are r's drop zones by edge: a band a dropZoneShare-th deep along +// each side, the top and bottom ones between the side bands, and the middle. +func paneZones(r image.Rectangle) [edgeCenter + 1]image.Rectangle { + dx, dy := r.Dx()/dropZoneShare, r.Dy()/dropZoneShare + var z [edgeCenter + 1]image.Rectangle + z[edgeLeft] = image.Rect(r.Min.X, r.Min.Y, r.Min.X+dx, r.Max.Y) + z[edgeRight] = image.Rect(r.Max.X-dx, r.Min.Y, r.Max.X, r.Max.Y) + z[edgeTop] = image.Rect(r.Min.X+dx, r.Min.Y, r.Max.X-dx, r.Min.Y+dy) + z[edgeBottom] = image.Rect(r.Min.X+dx, r.Max.Y-dy, r.Max.X-dx, r.Max.Y) + z[edgeCenter] = image.Rect(r.Min.X+dx, r.Min.Y+dy, r.Max.X-dx, r.Max.Y-dy) + return z +} + +// dropPreview is the part of r a pane dropped on edge would take. +func dropPreview(r image.Rectangle, edge paneEdge) image.Rectangle { + cx, cy := r.Min.X+r.Dx()/2, r.Min.Y+r.Dy()/2 + switch edge { + case edgeLeft: + r.Max.X = cx + case edgeRight: + r.Min.X = cx + case edgeTop: + r.Max.Y = cy + case edgeBottom: + r.Min.Y = cy + } + return r +} + +// dragPointer is where the pointer is while anything is carried over the +// window, read by a handler on the whole window: an ancestor of every area, +// it hears a drag whatever the drag started on. The drop zone under it is +// worked out from where the panes were drawn, rather than from Enter events +// on each zone: Gio's transfer delivers those unreliably to targets laid out +// once a drag is already under way. +type dragPointer struct { + tag int + at image.Point + // rects are where each pane, or the session alone, is drawn this frame, + // in window pixels, and last where they were drawn the frame before, + // which the pointer and a split are measured against; whole is a session + // alone, not a pane of a layout. + rects, last map[string]image.Rectangle + whole, was map[string]bool + // paneW, paneH and gap are the smallest pane and the gap between two, in + // window pixels. + paneW, paneH, gap int + // hover is the drop zone under the pointer, "" for none. + hover string + // released is the pointer let go this frame. + released bool + // tab is the strip tab the pointer has rested on since tabSince while + // carrying, which comes on screen once it has rested tabDwell. + tab string + tabSince time.Time +} + +// tabDwell is how long something carried rests on a tab before that tab +// comes on screen, as a browser switches to the tab a dragged file rests on: +// long enough that passing over the strip switches nothing. +const tabDwell = 600 * time.Millisecond + +// readDragPointer follows the pointer and, when what is carried is let go +// over a drop zone, records the drop for applyLayoutStage. Call it before +// the frame lays anything out: it reads where the panes were drawn last. +func (u *window) readDragPointer(gtx C) { + st := &u.layoutStage + d := &st.pointer + d.last, d.was = d.rects, d.whole + d.rects, d.whole = map[string]image.Rectangle{}, map[string]bool{} + m := st.metric + d.paneW, d.paneH, d.gap = m.Dp(paneMinWidth), m.Dp(paneMinHeight), m.Dp(gutter) + u.sidebar.carry.Hold(gtx) + carried := u.sidebar.carry.Carrying() + d.released = false + for { + ev, ok := gtx.Event(pointer.Filter{Target: &d.tag, Kinds: pointer.Move | pointer.Drag | pointer.Release}) + if !ok { + break + } + e, ok := ev.(pointer.Event) + if !ok { + continue + } + d.at = e.Position.Round() + d.released = d.released || e.Kind == pointer.Release + if e.Kind == pointer.Release && carried != "" { + if z := d.zoneAt(d.at, carried); z != "" { + st.drop = ui.SortDrop{ID: carried, Over: z} + } + } + } + d.hover = "" + if carried != "" { + d.hover = d.zoneAt(d.at, carried) + } + u.dwellOnTab(gtx, carried) +} + +// dwellOnTab switches to the strip tab something carried has rested on for +// tabDwell, the drop then landing on that tab's panes. +func (u *window) dwellOnTab(gtx C, carried string) { + d := &u.layoutStage.pointer + id := "" + if carried != "" && !d.released { + id = u.topbar.tabAt(d.at) + } + if _, active := u.model.stripTabs(); id == active { + id = "" + } + switch { + case id == "": + d.tab = "" + case id != d.tab: + d.tab, d.tabSince = id, gtx.Now + gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(tabDwell)}) + case gtx.Now.Sub(d.tabSince) >= tabDwell: + u.layoutStage.switchTab, d.tab = id, "" + default: + gtx.Execute(op.InvalidateCmd{At: d.tabSince.Add(tabDwell)}) + } +} + +// drawCarried draws the pill that follows the pointer while a session is +// carried: a pane dragged by its header anywhere, a card once it is past the +// sidebar, which draws it itself while it is over the list. +func (u *window) drawCarried(gtx C) { + carried := u.sidebar.carry.Carrying() + d := &u.layoutStage.pointer + if carried == "" || u.layoutStage.sort.Dragging() == "" && d.at.X < u.islandAt.X-gtx.Dp(gutter) { + return + } + sv, ok := u.model.viewsOf([]string{carried}, gtx.Now)[carried] + if !ok { + return + } + defer pointer.PassOp{}.Push(gtx.Ops).Pop() + u.sidebar.inHand(gtx, u.theme, &sv, d.at) +} + +// endStrandedDrag lets go of a drag whose own list was not laid out to hear +// the pointer go up, as when the tab it started on is no longer on screen. +func (u *window) endStrandedDrag(gtx C) { + if u.layoutStage.pointer.released && u.sidebar.carry.Carrying() != "" { + u.sidebar.carry.Release() + gtx.Execute(op.InvalidateCmd{}) + } +} + +// fits reports whether target's pane, as last drawn, holds two panes of the +// smallest size side by side for edge left or right, stacked for top or +// bottom; the middle swaps and always fits. +func (d *dragPointer) fits(target string, edge paneEdge) bool { + r, ok := d.last[target] + if !ok { + return false + } + switch edge { + case edgeLeft, edgeRight: + return r.Dx() >= 2*d.paneW+d.gap + case edgeTop, edgeBottom: + return r.Dy() >= 2*d.paneH+d.gap + } + return true +} + +// zoneAt is the drop zone at p of a pane other than carried, "" for none. +// Only a pane of the layout on screen swaps with another, through the +// middle; a card from the sidebar or a session alone has none. +func (d *dragPointer) zoneAt(p image.Point, carried string) string { + _, carriedIsPane := d.last[carried] + carriedIsPane = carriedIsPane && !d.was[carried] + for id, r := range d.last { + if id == carried || !p.In(r) { + continue + } + var allowed [edgeCenter + 1]bool + for _, edge := range []paneEdge{edgeLeft, edgeRight, edgeTop, edgeBottom} { + allowed[edge] = d.fits(id, edge) + } + allowed[edgeCenter] = carriedIsPane && !d.was[id] + if edge, ok := pickZone(r, p, allowed); ok { + return paneTargetID(id, edge) + } + } + return "" +} + +// pickZone is the zone of r the pointer at p is in, among those allowed: the +// middle while inside its band, else the nearest allowed edge, measured as a +// share of r's width or height, so an edge left out gives its area to the +// others. +func pickZone(r image.Rectangle, p image.Point, allowed [edgeCenter + 1]bool) (paneEdge, bool) { + if allowed[edgeCenter] && p.In(paneZones(r)[edgeCenter]) { + return edgeCenter, true + } + share := map[paneEdge]float64{ + edgeLeft: float64(p.X-r.Min.X) / float64(r.Dx()), + edgeRight: float64(r.Max.X-p.X) / float64(r.Dx()), + edgeTop: float64(p.Y-r.Min.Y) / float64(r.Dy()), + edgeBottom: float64(r.Max.Y-p.Y) / float64(r.Dy()), + } + best, found := edgeLeft, false + for _, edge := range []paneEdge{edgeLeft, edgeRight, edgeTop, edgeBottom} { + if allowed[edge] && (!found || share[edge] < share[best]) { + best, found = edge, true + } + } + return best, found +} + +// paneDropZones records where target is drawn, local in gtx and at in the +// window, and draws where the carried pane would land while the pointer is +// over one of its zones. whole is a session alone, not a pane of a layout. +func (u *window) paneDropZones(gtx C, target string, local, at image.Rectangle, whole bool) { + d := &u.layoutStage.pointer + if d.rects == nil { + return + } + d.rects[target], d.whole[target] = at, whole + over, edge, ok := parsePaneTarget(d.hover) + if !ok || over != target { + return + } + u.dropHighlight(gtx, dropPreview(local, edge), dropText(edge)) +} + +func dropText(edge paneEdge) string { + switch edge { + case edgeLeft: + return i18n.T("layouts.drop.left") + case edgeRight: + return i18n.T("layouts.drop.right") + case edgeTop: + return i18n.T("layouts.drop.top") + case edgeBottom: + return i18n.T("layouts.drop.bottom") + } + return i18n.T("layouts.drop.swap") +} + +// dropHighlight rings r and names the drop in its middle. It leaves the +// terminal under it unfilled: a translucent fill blends in linear light here +// and greys the text out, an opaque one hides it. +func (u *window) dropHighlight(gtx C, r image.Rectangle, text string) { + th := u.theme + rr := gtx.Dp(paneRadius) + shape := clip.UniformRRect(r, rr) + paint.FillShape(gtx.Ops, th.Ring, clip.Stroke{Path: shape.Path(gtx.Ops), Width: float32(gtx.Dp(2))}.Op()) + m := op.Record(gtx.Ops) + lgtx := gtx + lgtx.Constraints = layout.Constraints{Max: r.Size()} + on := th.On(th.Popover) + d := layout.Background{}.Layout(lgtx, + func(gtx C) D { return ui.Fill(gtx, th.Popover, ui.RadiusMD) }, + func(gtx C) D { + return layout.UniformInset(dropLabel).Layout(gtx, on.Text(ui.TextXS, text).Weight(font.Medium).Layout) + }, + ) + call := m.Stop() + defer op.Offset(r.Min.Add(r.Size().Sub(d.Size).Div(2))).Push(gtx.Ops).Pop() + call.Add(gtx.Ops) +} + +// dropOnPane carries out a drop of session on a pane's zone, which +// zoneAt offered only where it fits: a pane of the same layout moves or +// swaps, a session in another layout is asked about, any other splits the +// pane. +func (u *window) dropOnPane(session, over string) { + target, edge, ok := parsePaneTarget(over) + if !ok || target == session { + return + } + layoutID := u.model.layoutOf(target) + from := u.model.layoutOf(session) + if layoutID != "" && from == layoutID { + u.model.movePane(u.ctx, session, target, edge) + u.model.invalidate() + return + } + u.besidePicked(besideRequest{target: target, edge: edge}, session) +} diff --git a/native/cmd/lich-native/layouts_drag_test.go b/native/cmd/lich-native/layouts_drag_test.go new file mode 100644 index 00000000..01584059 --- /dev/null +++ b/native/cmd/lich-native/layouts_drag_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "image" + "testing" +) + +func TestPaneTargetRoundTrips(t *testing.T) { + for _, edge := range []paneEdge{edgeLeft, edgeRight, edgeTop, edgeBottom, edgeCenter} { + session, got, ok := parsePaneTarget(paneTargetID("s|1", edge)) + if !ok || session != "s|1" || got != edge { + t.Errorf("edge %d: got %q %d %v", edge, session, got, ok) + } + } + for _, id := range []string{"", "worktree-key", "pane|", "pane|s|9", "pane|s|x", "pane||1"} { + if _, _, ok := parsePaneTarget(id); ok { + t.Errorf("%q read as a drop zone", id) + } + } +} + +func TestPaneZonesTileThePane(t *testing.T) { + r := image.Rect(10, 20, 410, 220) + z := paneZones(r) + want := map[paneEdge]image.Rectangle{ + edgeLeft: image.Rect(10, 20, 110, 220), + edgeRight: image.Rect(310, 20, 410, 220), + edgeTop: image.Rect(110, 20, 310, 70), + edgeBottom: image.Rect(110, 170, 310, 220), + edgeCenter: image.Rect(110, 70, 310, 170), + } + area := 0 + for edge, w := range want { + if z[edge] != w { + t.Errorf("edge %d: got %v, want %v", edge, z[edge], w) + } + area += w.Dx() * w.Dy() + } + if area != r.Dx()*r.Dy() { + t.Fatalf("zones cover %d of %d", area, r.Dx()*r.Dy()) + } + if got := dropPreview(r, edgeRight); got != image.Rect(210, 20, 410, 220) { + t.Errorf("right preview %v", got) + } + if got := dropPreview(r, edgeTop); got != image.Rect(10, 20, 410, 120) { + t.Errorf("top preview %v", got) + } +} + +// A pane of the layout carried over another swaps through its middle; a +// pane too narrow for two side by side offers only above and below; a card +// gets no middle; the pane carried is no target. +func TestZoneAtOffersOnlyWhatFits(t *testing.T) { + d := dragPointer{ + last: map[string]image.Rectangle{"wide": image.Rect(0, 0, 1000, 600), "narrow": image.Rect(1006, 0, 1506, 600)}, + was: map[string]bool{}, + paneW: 480, paneH: 180, gap: 6, + } + cases := []struct { + at image.Point + carried string + want string + }{ + {image.Pt(500, 300), "narrow", paneTargetID("wide", edgeCenter)}, + {image.Pt(990, 300), "narrow", paneTargetID("wide", edgeRight)}, + {image.Pt(1490, 310), "wide", paneTargetID("narrow", edgeBottom)}, + {image.Pt(1256, 310), "card", paneTargetID("narrow", edgeBottom)}, + {image.Pt(500, 310), "card", paneTargetID("wide", edgeBottom)}, + {image.Pt(500, 300), "wide", ""}, + {image.Pt(1003, 300), "card", ""}, + } + for _, c := range cases { + if got := d.zoneAt(c.at, c.carried); got != c.want { + t.Errorf("at %v carrying %q: got %q, want %q", c.at, c.carried, got, c.want) + } + } + d.was["wide"] = true + if got := d.zoneAt(image.Pt(500, 250), "narrow"); got != paneTargetID("wide", edgeTop) { + t.Errorf("a session alone offered %q, want no middle", got) + } +} + +func TestPickZoneGivesALeftOutEdgeToTheOthers(t *testing.T) { + r := image.Rect(0, 0, 400, 400) + stackedOnly := [edgeCenter + 1]bool{edgeTop: true, edgeBottom: true} + cases := []struct { + at image.Point + allowed [edgeCenter + 1]bool + want paneEdge + ok bool + }{ + {image.Pt(200, 300), stackedOnly, edgeBottom, true}, + {image.Pt(10, 150), stackedOnly, edgeTop, true}, + {image.Pt(200, 200), [edgeCenter + 1]bool{true, true, true, true, true}, edgeCenter, true}, + {image.Pt(395, 200), [edgeCenter + 1]bool{true, true, true, true, true}, edgeRight, true}, + {image.Pt(200, 200), [edgeCenter + 1]bool{}, 0, false}, + } + for _, c := range cases { + got, ok := pickZone(r, c.at, c.allowed) + if ok != c.ok || ok && got != c.want { + t.Errorf("at %v: got %d %v, want %d %v", c.at, got, ok, c.want, c.ok) + } + } +} diff --git a/native/cmd/lich-native/layouts_test.go b/native/cmd/lich-native/layouts_test.go new file mode 100644 index 00000000..bd8c6493 --- /dev/null +++ b/native/cmd/lich-native/layouts_test.go @@ -0,0 +1,225 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// paneShape spells a tree as row(a,column(b,c)), the ratio left out. +func paneShape(n *paneNode) string { + if n == nil { + return "" + } + if n.leaf() { + return n.Session + } + return string(n.Axis) + "(" + paneShape(n.First) + "," + paneShape(n.Second) + ")" +} + +func TestSplitPutsTheSessionAgainstTheEdge(t *testing.T) { + cases := []struct { + edge paneEdge + want string + }{ + {edgeRight, "row(a,b)"}, + {edgeLeft, "row(b,a)"}, + {edgeBottom, "column(a,b)"}, + {edgeTop, "column(b,a)"}, + {edgeCenter, "b"}, + } + for _, c := range cases { + tab := layoutTab{Root: &paneNode{Session: "a"}} + if !tab.split("a", "b", c.edge) { + t.Fatalf("edge %d: split reported a missing target", c.edge) + } + if got := paneShape(tab.Root); got != c.want { + t.Errorf("edge %d: got %s, want %s", c.edge, got, c.want) + } + } +} + +func TestSplitNestsInsideTheTargetPaneOnly(t *testing.T) { + var ls layoutTabs + id := ls.open("orchestration", "orch", "w1", edgeRight) + tab := ls.byID(id) + tab.split("w1", "w2", edgeBottom) + if got, want := paneShape(tab.Root), "row(orch,column(w1,w2))"; got != want { + t.Fatalf("got %s, want %s", got, want) + } + if got := tab.sessions(); !reflect.DeepEqual(got, []string{"orch", "w1", "w2"}) { + t.Fatalf("reading order %v", got) + } + if tab.split("missing", "w3", edgeRight) { + t.Fatal("split into a session the tab does not hold succeeded") + } +} + +func TestRemoveCollapsesTheParentAndMovesFocus(t *testing.T) { + var ls layoutTabs + tab := ls.byID(ls.open("x", "a", "b", edgeRight)) + tab.split("b", "c", edgeBottom) + tab.Focus = "b" + if !tab.remove("b") { + t.Fatal("tab reported empty with two panes left") + } + if got, want := paneShape(tab.Root), "row(a,c)"; got != want { + t.Fatalf("got %s, want %s", got, want) + } + if tab.Focus != "a" { + t.Fatalf("focus %q, want the first pane left", tab.Focus) + } +} + +func TestTakeDissolvesALayoutLeftWithOnePane(t *testing.T) { + var ls layoutTabs + tab := ls.byID(ls.open("x", "a", "b", edgeRight)) + tab.split("b", "c", edgeBottom) + if left, ok := ls.take("c"); !ok || left != "" || len(ls.tabs) != 1 { + t.Fatalf("two panes left: left %q ok %v tabs %+v, want the layout kept", left, ok, ls.tabs) + } + if left, ok := ls.take("a"); !ok || left != "b" || len(ls.tabs) != 0 { + t.Fatalf("one pane left: left %q ok %v tabs %+v, want the layout gone and b alone", left, ok, ls.tabs) + } + if _, ok := ls.take("b"); ok { + t.Fatal("taking a session no layout holds reported a change") + } +} + +func TestPruneDropsSessionsThatAreGone(t *testing.T) { + var ls layoutTabs + ls.open("x", "a", "b", edgeRight) + ls.open("y", "c", "d", edgeRight) + ls.byID(ls.tabs[1].ID).split("d", "e", edgeBottom) + live := map[string]bool{"a": true, "c": true, "e": true} + if !ls.prune(func(id string) bool { return live[id] }) { + t.Fatal("prune reported no change") + } + if len(ls.tabs) != 1 || paneShape(ls.tabs[0].Root) != "row(c,e)" { + t.Fatalf("got %+v, want x gone with one pane left and y holding c and e", ls.tabs) + } +} + +func TestOfFindsTheLayoutHoldingASession(t *testing.T) { + var ls layoutTabs + ls.open("x", "a", "b", edgeRight) + ls.open("y", "c", "d", edgeRight) + if ls.of("d") != 1 || ls.of("a") != 0 || ls.of("z") != -1 { + t.Fatalf("of: d=%d a=%d z=%d", ls.of("d"), ls.of("a"), ls.of("z")) + } +} + +func TestMinSizeAddsAlongTheAxisAndMaxesAcross(t *testing.T) { + var ls layoutTabs + tab := ls.byID(ls.open("x", "a", "b", edgeRight)) + tab.split("b", "c", edgeBottom) + w, h := minSize(tab.Root, 100, 50, 6) + if w != 206 || h != 106 { + t.Fatalf("got %dx%d, want 206x106", w, h) + } +} + +func TestLayoutsRoundTrip(t *testing.T) { + var ls layoutTabs + tab := ls.byID(ls.open("orchestration", "a", "b", edgeRight)) + tab.split("b", "c", edgeBottom) + tab.Zoomed = true + raw, err := ls.marshal() + if err != nil { + t.Fatal(err) + } + got, err := parseLayouts(raw) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.tabs, ls.tabs) { + t.Fatalf("got %+v, want %+v", got.tabs, ls.tabs) + } + if id := got.open("next", "d", "e", edgeRight); id == tab.ID { + t.Fatalf("a restored set handed out a stored id again: %s", id) + } +} + +func TestParseLayoutsRepairsWhatItCanAndRefusesBrokenTrees(t *testing.T) { + got, err := parseLayouts(`[ + {"id":"layout-1","name":"x","root":{"axis":"row","ratio":2,"first":{"session":"a"},"second":{"session":"b"}},"focus":"gone"}, + {"id":"layout-7","name":"y","root":{"session":"a"}} + ]`) + if err != nil { + t.Fatal(err) + } + if len(got.tabs) != 1 { + t.Fatalf("the layout whose only pane was a duplicate stayed: %+v", got.tabs) + } + tab := got.tabs[0] + if tab.Root.Ratio != ratioMax || tab.Focus != "a" || got.seq != 7 { + t.Fatalf("ratio %v focus %q seq %d", tab.Root.Ratio, tab.Focus, got.seq) + } + for _, raw := range []string{ + `{`, + `[{"id":"layout-1","root":{"axis":"diagonal","first":{"session":"a"},"second":{"session":"b"}}}]`, + `[{"id":"layout-1","root":{"axis":"row","first":{"session":"a"}}}]`, + } { + if _, err := parseLayouts(raw); err == nil || !strings.Contains(err.Error(), layoutsKey) { + t.Errorf("%s: err %v, want one naming %s", raw, err, layoutsKey) + } + } + if empty, err := parseLayouts(""); err != nil || len(empty.tabs) != 0 { + t.Fatalf("unset setting: %+v, %v", empty, err) + } +} + +func TestMoveTakesThePaneOutAndSplitsTheTarget(t *testing.T) { + cases := []struct { + name, source, target string + edge paneEdge + want string + }{ + {"side by side to stacked", "b", "a", edgeBottom, "row(column(a,b),c)"}, + {"one beside two stacked", "b", "c", edgeBottom, "row(a,column(c,b))"}, + {"swap", "a", "c", edgeCenter, "row(row(c,b),a)"}, + } + for _, c := range cases { + tab := layoutTab{} + tab.Root = &paneNode{Axis: splitRow, Ratio: 0.5, First: &paneNode{Axis: splitRow, Ratio: 0.5, First: &paneNode{Session: "a"}, Second: &paneNode{Session: "b"}}, Second: &paneNode{Session: "c"}} + if !tab.move(c.source, c.target, c.edge) { + t.Fatalf("%s: move refused", c.name) + } + if got := paneShape(tab.Root); got != c.want { + t.Errorf("%s: got %s, want %s", c.name, got, c.want) + } + } + tab := layoutTab{Root: &paneNode{Session: "a"}} + tab.split("a", "b", edgeRight) + if tab.move("a", "a", edgeLeft) || tab.move("a", "z", edgeLeft) { + t.Fatal("moved onto itself or onto a pane the tab does not hold") + } +} + +func TestRotateTurnsTheFocusedSplit(t *testing.T) { + tab := layoutTab{Root: &paneNode{Session: "a"}} + if tab.rotate("a") { + t.Fatal("rotated a pane alone in its tab") + } + tab.split("a", "b", edgeRight) + tab.split("b", "c", edgeBottom) + tab.rotate("c") + if got, want := paneShape(tab.Root), "row(a,row(b,c))"; got != want { + t.Fatalf("got %s, want %s", got, want) + } + tab.rotate("a") + if got, want := paneShape(tab.Root), "column(a,row(b,c))"; got != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestPaneHeaderNamesOnlyAWorktree(t *testing.T) { + root := sessionView{projectID: "e48f04f46a4a", path: "/src/lich"} + worktree := sessionView{projectID: "e48f04f46a4a", path: "/home/u/.local/share/lich/worktrees/e48f04f46a4a/imfl"} + if got := worktreeLabel(root, "/src/lich"); got != "" { + t.Errorf("a session in the project's own checkout is labelled %q, want none", got) + } + if got := worktreeLabel(worktree, "/src/lich"); got != "imfl" { + t.Errorf("worktree label = %q, want imfl", got) + } +} diff --git a/native/cmd/lich-native/layouts_window.go b/native/cmd/lich-native/layouts_window.go new file mode 100644 index 00000000..c17971c5 --- /dev/null +++ b/native/cmd/lich-native/layouts_window.go @@ -0,0 +1,111 @@ +package main + +import ( + "gioui.org/layout" + "gioui.org/widget" + + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" +) + +// The window's side of a split: picking the session, the room check, and +// the question before a session leaves the layout it is in. + +func (u *window) hasLayout() bool { + _, ok := u.model.shownLayoutTab() + return ok +} + +// hotkeyUnsplit takes the focused pane out of the layout on screen. +func (u *window) hotkeyUnsplit(C) { + active, ok := u.model.activeSession() + if !ok { + return + } + u.model.unsplit(u.ctx, active.ID) + u.model.invalidate() +} + +// pickBeside opens the session picker for a pane against the active +// session's edge. +func (u *window) pickBeside(edge paneEdge) { + active, ok := u.model.activeSession() + if !ok { + return + } + u.settings.hide() + u.palette.showBeside(turnsAmong(u.model.paletteWorld().sessions), active.ID, edge) +} + +// besidePicked splits the requested pane with session id: refused with a +// toast when that pane has no room for one more beside it, asked about first +// when id sits in another layout. +func (u *window) besidePicked(r besideRequest, id string) { + if id == r.target { + return + } + if !u.fitsBeside(r.target, r.edge) { + u.toaster.Show(ui.Toast{Title: i18n.T("layouts.split.noRoom")}) + return + } + from := u.model.layoutOf(id) + if from != "" && from != u.model.layoutOf(r.target) { + u.askMove(r, id, from) + return + } + if from != "" { + u.model.unsplit(u.ctx, id) + } + u.model.splitBeside(u.ctx, r.target, id, r.edge, false) + u.model.invalidate() +} + +// fitsBeside reports whether target's pane, as last drawn, has room to split +// against edge. +func (u *window) fitsBeside(target string, edge paneEdge) bool { + return u.layoutStage.pointer.fits(target, edge) +} + +// moveConfirm is the question before a split takes a session out of the +// layout it is in: the user built that layout, so it never loses a pane +// unasked. +type moveConfirm struct { + dialog ui.DialogState + open bool + req besideRequest + session string + label string + from string + cancel widget.Clickable + confirm widget.Clickable +} + +func (u *window) askMove(r besideRequest, id, from string) { + c := &u.layoutStage.move + c.open, c.req, c.session, c.from = true, r, id, u.model.layoutName(from) + c.label = u.model.sessionLabel(id) +} + +// layoutMoveConfirm draws the question and makes the move it is answered +// with. +func (u *window) layoutMoveConfirm(gtx C, th *ui.Theme) { + c := &u.layoutStage.move + if c.confirm.Clicked(gtx) && c.open { + c.open = false + u.model.splitBeside(u.ctx, c.req.target, c.session, c.req.edge, true) + u.model.invalidate() + } + if c.cancel.Clicked(gtx) { + c.open = false + } + on := th.On(th.Popover) + cancel := on.Button(&c.cancel, i18n.T("common.action.cancel")) + cancel.Variant = ui.VariantOutline + move := on.Button(&c.confirm, i18n.T("layouts.move.confirm")) + body := i18n.T("layouts.move.body", i18n.Args{"label": c.label, "layout": c.from}) + d := th.Dialog(&c.dialog, c.open, i18n.T("layouts.move.title", i18n.Args{"label": c.label}), body) + d.Actions = []layout.Widget{cancel.Layout, move.Layout} + if d.Layout(gtx) { + c.open = false + } +} diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 56386bfc..5476237d 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -7,7 +7,6 @@ package main import ( "context" "crypto/rand" - "errors" "flag" "fmt" "image" @@ -23,6 +22,7 @@ import ( "gioui.org/app" "gioui.org/font" "gioui.org/font/gofont" + "gioui.org/io/event" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" @@ -144,6 +144,8 @@ type window struct { reconnecting atomic.Bool // islandAt is the island's top-left in window pixels, where body put it. islandAt image.Point + // layoutStage is the panes' state while a layout is on screen. + layoutStage layoutStage // hotkeyLanguage is the language the hotkeys' labels were last put in. hotkeyLanguage i18n.Locale // fontsLanguage is the language the interface's CJK faces were last @@ -331,6 +333,12 @@ func (u *window) connect() error { if err := u.model.hydrateTurnStates(u.ctx); err != nil { return err } + // A stored value this build cannot read starts the window with no + // layouts and says so; the next split stores over it. + if err := u.model.loadLayouts(u.ctx); err != nil { + u.logErr("load layouts", err) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("layouts.load.failed", i18n.Args{"error": lichclient.ErrorText(err)})}) + } if _, ok := u.model.project(); !ok && *flagProject != "" { path, err := filepath.Abs(*flagProject) if err != nil { @@ -443,6 +451,9 @@ func (u *window) layout(gtx C) error { u.openShotMenu() } fired := u.pollHotkeys(gtx) + u.readDragPointer(gtx) + pointerArea := clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops) + event.Op(gtx.Ops, &u.layoutStage.pointer.tag) var act sidebarAction var top topbarAction var contentErr error @@ -477,10 +488,12 @@ func (u *window) layout(gtx C) error { u.layoutNotifyOptIn(gtx, u.theme) u.layoutGitMissing(gtx, u.theme) u.layoutExperimentalNotice(gtx, u.theme) + u.layoutMoveConfirm(gtx, u.theme) var edge int edge, act, contentErr = u.body(gtx, v) u.sidebar.flight(gtx, u.theme, u.model) top = u.topbar.layout(gtx, u.theme, u.topbarFrame(gtx, v, edge)) + u.drawCarried(gtx) top.close = closed toasts := u.theme.Toaster(&u.toaster, u.light) toasts.Expand = *flagToastsExpanded @@ -490,8 +503,11 @@ func (u *window) layout(gtx C) error { duplicates.Layout(gtx) return D{Size: gtx.Constraints.Max} }) + pointerArea.Pop() + u.endStrandedDrag(gtx) u.apply(act) u.applyTopbar(top) + u.applyLayoutStage() u.runHotkeys(gtx, fired) return contentErr } @@ -506,6 +522,19 @@ type stageView struct { in footerInput tabs []projectTab project string // the active tab's project + // strip is the topbar's tabs, the projects then the layouts, and + // stripActive the one on screen. + strip []projectTab + stripActive string + // layout is the layout on screen when hasLayout, with each pane's + // session; mixed is its panes coming from more than one project, whose + // names and paths the headers read. + layout layoutTab + hasLayout bool + panes map[string]sessionView + mixed bool + projectNames map[string]string + projectPaths map[string]string } func (u *window) stageView(now time.Time) stageView { @@ -513,6 +542,17 @@ func (u *window) stageView(now time.Time) stageView { v := stageView{in: u.footer.input(u.model, now), home: !shown} v.active, v.hasActive = u.model.activeSession() v.tabs, v.project = u.model.projectTabs() + v.strip, v.stripActive = u.model.stripTabs() + v.layout, v.hasLayout = u.model.shownLayoutTab() + if v.hasLayout { + v.panes = u.model.viewsOf(v.layout.sessions(), now) + v.projectNames, v.projectPaths = u.model.projectNamesAndPaths() + seen := map[string]bool{} + for _, sv := range v.panes { + seen[sv.projectID] = true + } + v.mixed = len(seen) > 1 + } return v } @@ -544,7 +584,7 @@ func (u *window) body(gtx C, v stageView) (int, sidebarAction, error) { if u.pulls.open && !v.home { u.pulls.files = filesInput{sessionID: v.in.sessionID, checkout: v.in.path, light: u.light, code: u.code} u.pulls.squareX = -1 - if x, ok := u.topbar.islandSquare(gtx, topbarFrame{tabs: v.tabs, active: v.project, islands: u.pulls.spans}); ok { + if x, ok := u.topbar.islandSquare(gtx, topbarFrame{tabs: v.strip, active: v.stripActive, islands: u.pulls.spans}); ok { u.pulls.squareX = x } u.applyPulls(u.pulls.layout(gtx, u.theme)) @@ -568,7 +608,7 @@ func (u *window) island(gtx C, v stageView) error { gtx.Constraints.Min = size r := gtx.Dp(ui.RadiusLG) nw := r - if _, ok := u.topbar.islandSquare(gtx, topbarFrame{tabs: v.tabs, active: v.project}); ok { + if _, ok := u.topbar.islandSquare(gtx, topbarFrame{tabs: v.strip, active: v.stripActive}); ok { nw = 0 } defer clip.RRect{Rect: image.Rectangle{Max: size}, NW: nw, NE: r, SE: r, SW: r}.Push(gtx.Ops).Pop() @@ -585,11 +625,15 @@ func (u *window) island(gtx C, v stageView) error { case v.home: homeView(gtx, u.theme.On(fill), &u.homeOpen) return nil + case v.hasLayout: + return u.layoutIsland(gtx, v) case !v.hasActive: emptySessionsView(gtx, u.theme.On(fill), &u.emptyNew, u.model.newSessionKind() == "shell") return nil } - if err := u.content(gtx, v.active); err != nil { + u.layoutStage.metric = gtx.Metric + u.terms.onScreen(v.active.ID) + if err := u.content(gtx, v.active, u.terminalTakesKeys(gtx)); err != nil { return err } if e, ok := u.terms.exitOf(v.active.ID); ok { @@ -601,6 +645,7 @@ func (u *window) island(gtx C, v stageView) error { u.closeSession(v.active.ID) } } + u.paneDropZones(gtx, v.active.ID, image.Rectangle{Max: size}, image.Rectangle{Max: size}.Add(u.islandAt), true) if u.terms.has(v.active.ID) { u.drop.setRect(image.Rectangle{Min: u.islandAt, Max: u.islandAt.Add(size)}) if u.drop.hinting() || *flagDropHint { @@ -613,17 +658,20 @@ func (u *window) island(gtx C, v stageView) error { // islandColor is the terminal's own background while it is on the island, // else the theme's. func (u *window) islandColor(v stageView) color.NRGBA { - if v.home || !v.hasActive || u.terms.background.A == 0 { + if v.home || !v.hasActive || v.hasLayout { return u.theme.Background } - return u.terms.background + if bg := u.terms.backgroundOf(v.active.ID); bg.A != 0 { + return bg + } + return u.theme.Background } func (u *window) topbarFrame(gtx C, v stageView, edge int) topbarFrame { width, name := u.sidebar.column(gtx) f := topbarFrame{ brandWidth: width, brandName: name, stripX: edge, island: u.islandColor(v), - tabs: v.tabs, active: v.project, pending: u.model.notifications(), + tabs: v.strip, active: v.stripActive, pending: u.model.notifications(), } // Settings is no project's screen, so no tab merges into it. if u.settings.open { @@ -723,32 +771,10 @@ func (u *window) lostNotice(gtx C) D { }) } -func (u *window) content(gtx C, active sessionView) error { - resume, ok := "", u.terms.has(active.ID) - if !ok { - resume, ok = u.gate.release(u.ctx, active) - } - if !ok { - return nil - } - t, err := u.terms.show(u.ctx, gtx, active, resume) - if err != nil { - return err - } - err = u.terms.layout(gtx, t, u.terminalTakesKeys(gtx)) - // A reconnect frees every terminal off the frame goroutine; one shown - // since is replaced on the next frame, which the window must survive. - if errors.Is(err, gioterm.ErrClosed) { - gtx.Execute(op.InvalidateCmd{}) - return nil - } - return err -} - // terminalTakesKeys is whether the terminal on screen takes the keyboard back // this frame: not while a field or a dialog over it is typing. func (u *window) terminalTakesKeys(gtx C) bool { - typing := u.sidebar.holdsKeys() || u.footer.holdsKeys() || u.dock.holdsKeys(gtx) + typing := u.sidebar.holdsKeys() || u.footer.holdsKeys() || u.dock.holdsKeys(gtx) || u.topbar.holdsKeys() return !typing && !u.palette.open && !u.worktree.open } diff --git a/native/cmd/lich-native/model.go b/native/cmd/lich-native/model.go index 4badd027..37683ae9 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -108,6 +108,11 @@ type model struct { dropped func() // closed is the closed projects, which the menu and the palette read. closed *closedProjects + + // layouts are the window's split layouts; shownLayout is the one on + // screen, "" for a project's session alone. + layouts layoutTabs + shownLayout string } // sessionCwd is a session-cwd payload: where the session's shell is, or the @@ -166,7 +171,14 @@ func (m *model) reload(ctx context.Context) error { if p := m.shownProject(); p != nil && !slices.ContainsFunc(p.Sessions, func(s lichclient.Session) bool { return s.ID == m.active }) { m.active = p.ActiveSessionID } + pruned := m.layouts.prune(m.liveLocked) + if pruned { + m.refocusLayoutLocked() + } m.mu.Unlock() + if pruned { + m.saveLayouts() + } m.invalidate() return nil } @@ -214,6 +226,7 @@ func (m *model) setActiveProject(id string) bool { } m.activeProject, m.home = id, id == m.homeID m.active = m.projects[i].ActiveSessionID + m.shownLayout = "" return true } @@ -557,6 +570,7 @@ func (m *model) activeSession() (sessionView, bool) { // (project-events.tsx). func (m *model) setActive(ctx context.Context, id string) { m.mu.Lock() + refocused := m.routeLocked(id) left := m.active m.active = id if p := m.shownProject(); p != nil { @@ -565,6 +579,9 @@ func (m *model) setActive(ctx context.Context, id string) { readLeft := left != id && m.status.markSeen(left) readNew := m.focused && m.status.markSeen(id) m.mu.Unlock() + if refocused { + m.saveLayouts() + } if readLeft { m.markRead(ctx, left) } @@ -596,6 +613,16 @@ func (m *model) closeSession(id string) (projectID, active string, ok bool) { return "", "", false } p.Sessions = slices.Delete(p.Sessions, i, i+1) + if left, took := m.layouts.take(id); took { + m.saveLayoutsLocked() + if m.active == id && m.refocusLayoutLocked() { + return p.ID, m.active, true + } + if lp, _ := m.sessionAt(left); m.active == id && lp != nil { + m.active, m.activeProject = left, lp.ID + return lp.ID, left, true + } + } if m.active == id { m.active = "" switch { diff --git a/native/cmd/lich-native/model_layouts.go b/native/cmd/lich-native/model_layouts.go new file mode 100644 index 00000000..8b0cc57d --- /dev/null +++ b/native/cmd/lich-native/model_layouts.go @@ -0,0 +1,465 @@ +package main + +import ( + "context" + "log" + "slices" + "strings" + "time" +) + +// The layouts on the model: which one is on screen, and the routing that +// keeps a session in one place. A session a layout holds is always shown in +// that layout; any other is shown alone in its project, which takes the +// layout off screen. + +// loadLayouts reads the stored layouts once the workspace is loaded, and +// drops the panes of sessions closed while the window was away. +func (m *model) loadLayouts(ctx context.Context) error { + raw, err := m.client.GetSetting(ctx, layoutsKey, "") + if err != nil { + return err + } + ls, err := parseLayouts(raw) + if err != nil { + return err + } + m.mu.Lock() + m.layouts = ls + pruned := m.layouts.prune(m.liveLocked) + m.mu.Unlock() + if pruned { + m.saveLayouts() + } + return nil +} + +// saveLayouts stores the layouts as they are now, off the caller. +func (m *model) saveLayouts() { + m.mu.Lock() + defer m.mu.Unlock() + m.saveLayoutsLocked() +} + +// saveLayoutsLocked is saveLayouts for a caller holding m.mu. +func (m *model) saveLayoutsLocked() { + raw, err := m.layouts.marshal() + if err != nil { + log.Printf("save layouts: %v", err) + return + } + go func() { + if err := m.client.SetSetting(context.Background(), layoutsKey, "", raw); err != nil { + log.Printf("save layouts: %v", err) + } + }() +} + +// liveLocked reports whether an open project holds session id. Callers hold +// m.mu. +func (m *model) liveLocked(id string) bool { + p, _ := m.sessionAt(id) + return p != nil +} + +// routeLocked puts on screen the place session id is shown in: the layout +// holding it, focused on it, else its project with no layout. It reports +// whether the layouts changed. Callers hold m.mu. +func (m *model) routeLocked(id string) bool { + p, _ := m.sessionAt(id) + if p == nil { + return false + } + m.activeProject, m.home = p.ID, p.ID == m.homeID + i := m.layouts.of(id) + if i < 0 { + m.shownLayout = "" + return false + } + t := &m.layouts.tabs[i] + m.shownLayout = t.ID + if t.Focus == id { + return false + } + t.Focus = id + return true +} + +// refocusLayoutLocked puts the focused pane of the layout on screen back +// on screen after the layouts changed under it, and reports false when that +// layout is gone, which takes it off screen. Callers hold m.mu. +func (m *model) refocusLayoutLocked() bool { + t := m.layouts.byID(m.shownLayout) + if t == nil { + m.shownLayout = "" + return false + } + m.active = t.Focus + if p, _ := m.sessionAt(t.Focus); p != nil { + m.activeProject = p.ID + } + return true +} + +// showLayout puts layout id on screen on the pane it last had in focus, and +// reports false when no layout has that id. +func (m *model) showLayout(ctx context.Context, id string) bool { + m.mu.Lock() + t := m.layouts.byID(id) + if t == nil { + m.mu.Unlock() + return false + } + focus := t.Focus + m.mu.Unlock() + m.setActive(ctx, focus) + return true +} + +// shownLayoutTab is a copy of the layout on screen, false with none. +func (m *model) shownLayoutTab() (layoutTab, bool) { + m.mu.Lock() + defer m.mu.Unlock() + return m.shownLayoutLocked() +} + +func (m *model) shownLayoutLocked() (layoutTab, bool) { + if m.shownLayout == "" || m.home { + return layoutTab{}, false + } + t := m.layouts.byID(m.shownLayout) + if t == nil { + return layoutTab{}, false + } + c := *t + c.Root = cloneNode(t.Root) + return c, true +} + +func cloneNode(n *paneNode) *paneNode { + if n == nil { + return nil + } + c := *n + c.First, c.Second = cloneNode(n.First), cloneNode(n.Second) + return &c +} + +// layoutOf is the id of the layout holding session id, "" for none. +func (m *model) layoutOf(id string) string { + m.mu.Lock() + defer m.mu.Unlock() + if i := m.layouts.of(id); i >= 0 { + return m.layouts.tabs[i].ID + } + return "" +} + +// splitBeside puts session against target's edge and brings it into focus: +// in the layout holding target, else in a new layout of the two named after +// target. A session that is in another layout already stays where it is +// unless move is set; it reports false when nothing was split. +func (m *model) splitBeside(ctx context.Context, target, session string, edge paneEdge, move bool) bool { + m.mu.Lock() + if target == session || !m.liveLocked(target) || !m.liveLocked(session) { + m.mu.Unlock() + return false + } + if at := m.layouts.of(session); at >= 0 { + if !move { + m.mu.Unlock() + return false + } + m.layouts.take(session) + } + if i := m.layouts.of(target); i >= 0 { + m.layouts.tabs[i].split(target, session, edge) + } else { + p, i := m.sessionAt(target) + m.layouts.open(p.Sessions[i].Label, target, session, edge) + } + m.mu.Unlock() + m.setActive(ctx, session) + m.saveLayouts() + return true +} + +// unsplit takes session out of its layout, leaving the session running. The +// pane that takes the focus comes on screen, or, with one pane left and so +// no layout, that pane's session alone. +func (m *model) unsplit(ctx context.Context, session string) { + m.mu.Lock() + i := m.layouts.of(session) + if i < 0 { + m.mu.Unlock() + return + } + id := m.layouts.tabs[i].ID + left, _ := m.layouts.take(session) + next := left + if t := m.layouts.byID(id); t != nil { + next = t.Focus + } + m.mu.Unlock() + m.setActive(ctx, next) + m.saveLayouts() +} + +// closeLayout drops layout id; its sessions keep running, the one it had in +// focus coming on screen alone. +func (m *model) closeLayout(ctx context.Context, id string) { + m.mu.Lock() + t := m.layouts.byID(id) + if t == nil { + m.mu.Unlock() + return + } + focus, shown := t.Focus, m.shownLayout == id + m.layouts.close(id) + m.mu.Unlock() + if shown { + m.setActive(ctx, focus) + } + m.saveLayouts() +} + +// toggleZoom zooms the focused pane of the layout on screen over the others, +// or back. +func (m *model) toggleZoom() { + m.mu.Lock() + t := m.layouts.byID(m.shownLayout) + if t == nil || m.home { + m.mu.Unlock() + return + } + t.Zoomed = !t.Zoomed + m.mu.Unlock() + m.saveLayouts() +} + +// cyclePane moves the focus of the layout on screen to the pane after it in +// reading order. +func (m *model) cyclePane(ctx context.Context, step int) { + t, ok := m.shownLayoutTab() + if !ok { + return + } + ids := t.sessions() + at := 0 + for i, id := range ids { + if id == t.Focus { + at = i + } + } + m.setActive(ctx, ids[(at+step+len(ids))%len(ids)]) +} + +// setRatio stores a divider's new ratio once its drag ends. +func (m *model) setRatio(layoutID string, path []bool, ratio float64) { + m.mu.Lock() + t := m.layouts.byID(layoutID) + if t == nil { + m.mu.Unlock() + return + } + n := t.Root + for _, second := range path { + if n.leaf() { + m.mu.Unlock() + return + } + if second { + n = n.Second + } else { + n = n.First + } + } + if n.leaf() { + m.mu.Unlock() + return + } + n.Ratio = clampRatio(ratio) + m.mu.Unlock() + m.saveLayouts() +} + +// layoutStripTabs are the layouts as strip tabs, each but the one on screen +// with the most urgent status of its panes. Callers hold m.mu. +func (m *model) layoutStripTabs() []projectTab { + tabs := make([]projectTab, 0, len(m.layouts.tabs)) + for _, t := range m.layouts.tabs { + ids := t.sessions() + labels := make([]string, 0, len(ids)) + for _, id := range ids { + if p, i := m.sessionAt(id); p != nil { + labels = append(labels, p.Sessions[i].Label) + } + } + tab := projectTab{ID: t.ID, Name: t.Name, Path: strings.Join(labels, ", "), layout: true} + if t.ID != m.shownLayout { + tab.status = m.status.projectStatus(ids) + } + tabs = append(tabs, tab) + } + return tabs +} + +// stripTabs is the whole strip, the projects and then the layouts, and the +// tab on screen. +func (m *model) stripTabs() ([]projectTab, string) { + tabs, active := m.projectTabs() + m.mu.Lock() + defer m.mu.Unlock() + tabs = append(tabs, m.layoutStripTabs()...) + if _, ok := m.shownLayoutLocked(); ok { + active = m.shownLayout + } + return tabs, active +} + +// isLayout reports whether a strip tab id is a layout's. +func (m *model) isLayout(id string) bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.layouts.byID(id) != nil +} + +// moveLayout puts layout id where over is in the strip, both being layouts; +// a project dragged among layouts, or a layout among projects, stays put. +func (m *model) moveLayout(id, over string) bool { + m.mu.Lock() + defer m.mu.Unlock() + from, to := -1, -1 + for i, t := range m.layouts.tabs { + switch t.ID { + case id: + from = i + case over: + to = i + } + } + if from < 0 || to < 0 { + return false + } + t := m.layouts.tabs[from] + m.layouts.tabs = slices.Insert(slices.Delete(m.layouts.tabs, from, from+1), to, t) + return true +} + +// viewsOf is what each of the sessions ids shows at now, by id; a session no +// open project holds is left out. +func (m *model) viewsOf(ids []string, now time.Time) map[string]sessionView { + m.mu.Lock() + defer m.mu.Unlock() + out := make(map[string]sessionView, len(ids)) + for _, id := range ids { + if p, i := m.sessionAt(id); p != nil { + out[id] = m.viewOf(*p, p.Sessions[i], now) + } + } + return out +} + +// projectNamesAndPaths are every open project's name and path, by id. +func (m *model) projectNamesAndPaths() (names, paths map[string]string) { + m.mu.Lock() + defer m.mu.Unlock() + names, paths = make(map[string]string, len(m.projects)), make(map[string]string, len(m.projects)) + for _, p := range m.projects { + names[p.ID], paths[p.ID] = p.Name, p.Path + } + return names, paths +} + +// layoutName is layout id's name, "" for no such layout. +func (m *model) layoutName(id string) string { + m.mu.Lock() + defer m.mu.Unlock() + if t := m.layouts.byID(id); t != nil { + return t.Name + } + return "" +} + +// sessionLabel is session id's label, "" for a session no open project holds. +func (m *model) sessionLabel(id string) string { + m.mu.Lock() + defer m.mu.Unlock() + if p, i := m.sessionAt(id); p != nil { + return p.Sessions[i].Label + } + return "" +} + +// renameLayout names layout id name, reporting false when no layout has +// that id. +func (m *model) renameLayout(id, name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + t := m.layouts.byID(id) + if t == nil { + return false + } + t.Name = name + return true +} + +// movePane puts source's pane against target's edge, or swaps the two on the +// middle, in the layout holding both, and focuses source. It reports false +// when they are not panes of one layout. +func (m *model) movePane(ctx context.Context, source, target string, edge paneEdge) bool { + m.mu.Lock() + i := m.layouts.of(target) + if i < 0 || m.layouts.of(source) != i || !m.layouts.tabs[i].move(source, target, edge) { + m.mu.Unlock() + return false + } + m.mu.Unlock() + m.setActive(ctx, source) + m.saveLayouts() + return true +} + +// rotateFocused turns the split holding the focused pane of the layout on +// screen between side by side and stacked. +func (m *model) rotateFocused() bool { + m.mu.Lock() + t := m.layouts.byID(m.shownLayout) + if t == nil || m.home || !t.rotate(t.Focus) { + m.mu.Unlock() + return false + } + m.mu.Unlock() + m.saveLayouts() + return true +} + +// showOtherThan puts another session of the project on screen when the one +// it shows alone is id, as when the session being dragged is the one its +// project last showed: what is dragged is never the only place to drop it. +// A session in no layout is picked first. +func (m *model) showOtherThan(ctx context.Context, id string) { + m.mu.Lock() + p := m.shownProject() + if p == nil || m.active != id || m.shownLayout != "" { + m.mu.Unlock() + return + } + pick := "" + for _, s := range p.Sessions { + if s.ID == id { + continue + } + if m.layouts.of(s.ID) < 0 { + pick = s.ID + break + } + if pick == "" { + pick = s.ID + } + } + m.mu.Unlock() + if pick != "" { + m.setActive(ctx, pick) + } +} diff --git a/native/cmd/lich-native/model_layouts_test.go b/native/cmd/lich-native/model_layouts_test.go new file mode 100644 index 00000000..323c6e63 --- /dev/null +++ b/native/cmd/lich-native/model_layouts_test.go @@ -0,0 +1,326 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync" + "testing" + "time" + + "github.com/lichdotdev/lich/native/lichclient" +) + +// layoutsBackend answers every call with null and keeps the last value +// stored under layoutsKey. +type layoutsBackend struct { + mu sync.Mutex + stored string + saves int +} + +func (b *layoutsBackend) last(t *testing.T, saves int) string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + b.mu.Lock() + n, s := b.saves, b.stored + b.mu.Unlock() + if n >= saves { + return s + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("layouts saved %d times, want %d", b.saves, saves) + return "" +} + +func newLayoutsModel(t *testing.T) (*model, *layoutsBackend) { + t.Helper() + b := new(layoutsBackend) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var args []json.RawMessage + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &args) + if r.URL.Path == "/rpc/store.SetSetting" && len(args) == 3 && string(args[0]) == strconv.Quote(layoutsKey) { + var v string + _ = json.Unmarshal(args[2], &v) + b.mu.Lock() + b.stored, b.saves = v, b.saves+1 + b.mu.Unlock() + } + _ = json.NewEncoder(w).Encode(nil) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(u.Port()) + m := newModel(lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}), func() {}) + m.projects = []lichclient.Project{ + {ID: "lich", Name: "lich", Path: "/dev/lich", ActiveSessionID: "orch", Sessions: []lichclient.Session{{ID: "orch", Label: "orchestrator"}, {ID: "notes", Label: "notes"}}}, + {ID: "plugin", Name: "lich-plugin", Path: "/dev/plugin", ActiveSessionID: "wa", Sessions: []lichclient.Session{{ID: "wa", Label: "worker-a"}, {ID: "wb", Label: "worker-b"}}}, + } + m.activeProject, m.active = "lich", "orch" + return m, b +} + +func layoutScreen(t *testing.T, m *model) (layout, project, active string) { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + lt, _ := m.shownLayoutLocked() + return lt.ID, m.activeProject, m.active +} + +func TestSplitBesideOpensALayoutAcrossProjects(t *testing.T) { + m, b := newLayoutsModel(t) + ctx := context.Background() + if !m.splitBeside(ctx, "orch", "wa", edgeRight, false) { + t.Fatal("split refused") + } + id, project, active := layoutScreen(t, m) + if id == "" || project != "plugin" || active != "wa" { + t.Fatalf("on screen: layout %q project %q active %q, want the new pane in focus", id, project, active) + } + tabs, shown := m.stripTabs() + last := tabs[len(tabs)-1] + if !last.layout || last.Name != "orchestrator" || last.Path != "orchestrator, worker-a" || shown != id { + t.Fatalf("strip %+v, shown %q", tabs, shown) + } + saved, err := parseLayouts(b.last(t, 1)) + if err != nil || len(saved.tabs) != 1 || paneShape(saved.tabs[0].Root) != "row(orch,wa)" { + t.Fatalf("stored %+v, %v", saved.tabs, err) + } +} + +func TestASessionInALayoutIsAlwaysShownThere(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + layoutID, _, _ := layoutScreen(t, m) + + m.setActive(ctx, "notes") + if id, project, active := layoutScreen(t, m); id != "" || project != "lich" || active != "notes" { + t.Fatalf("a session outside layouts: layout %q project %q active %q, want it alone", id, project, active) + } + m.setActive(ctx, "orch") + if id, project, active := layoutScreen(t, m); id != layoutID || project != "lich" || active != "orch" { + t.Fatalf("back to a pane: layout %q project %q active %q", id, project, active) + } + if !m.setActiveProject("plugin") { + t.Fatal("project not found") + } + if id, _, active := layoutScreen(t, m); id != "" || active != "wa" { + t.Fatalf("a project tab: layout %q active %q, want the project's own session", id, active) + } + if !m.showLayout(ctx, layoutID) { + t.Fatal("showLayout refused") + } + if id, _, active := layoutScreen(t, m); id != layoutID || active != "orch" { + t.Fatalf("the layout tab: layout %q active %q, want the pane it last focused", id, active) + } +} + +func TestSplitBesideAsksBeforeTakingAPaneFromAnotherLayout(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + m.splitBeside(ctx, "notes", "wb", edgeRight, false) + if m.splitBeside(ctx, "notes", "wa", edgeBottom, false) { + t.Fatal("moved a pane out of its layout without move") + } + if !m.splitBeside(ctx, "notes", "wa", edgeBottom, true) { + t.Fatal("refused the move it was told to make") + } + m.mu.Lock() + defer m.mu.Unlock() + // The move left orch's layout with orch alone, which is no layout. + if len(m.layouts.tabs) != 1 || paneShape(m.layouts.tabs[0].Root) != "row(column(notes,wa),wb)" { + t.Fatalf("got %+v", m.layouts.tabs) + } +} + +func TestClosingTheFocusedPaneFocusesWhatIsLeft(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + m.splitBeside(ctx, "wa", "wb", edgeBottom, false) + layoutID, _, _ := layoutScreen(t, m) + + if _, active, ok := m.closeSession("wb"); !ok || active != "orch" { + t.Fatalf("active %q after closing the focused pane, want the first pane left", active) + } + if id, project, _ := layoutScreen(t, m); id != layoutID || project != "lich" { + t.Fatalf("layout %q project %q", id, project) + } + m.closeSession("orch") + m.closeSession("wa") + if id, _, _ := layoutScreen(t, m); id != "" { + t.Fatalf("an emptied layout stayed on screen: %q", id) + } + if tabs, _ := m.stripTabs(); len(tabs) != 2 { + t.Fatalf("strip kept a layout with no pane: %+v", tabs) + } +} + +func TestUnsplitAndCloseLayoutLeaveTheSessionsRunning(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + m.splitBeside(ctx, "wa", "wb", edgeBottom, false) + layoutID, _, _ := layoutScreen(t, m) + m.unsplit(ctx, "wb") + if id, _, active := layoutScreen(t, m); id != layoutID || active != "orch" { + t.Fatalf("unsplit: layout %q active %q, want the pane left in focus", id, active) + } + m.closeLayout(ctx, layoutID) + if id, project, active := layoutScreen(t, m); id != "" || project != "lich" || active != "orch" { + t.Fatalf("closed layout: %q %q %q, want its focus alone", id, project, active) + } + if !m.liveLocked("wa") || !m.liveLocked("orch") || !m.liveLocked("wb") { + t.Fatal("a session closed with its layout") + } +} + +func TestTheLastPaneLeftClosesTheLayoutAndShowsItsSessionAlone(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + m.unsplit(ctx, "orch") + if id, project, active := layoutScreen(t, m); id != "" || project != "plugin" || active != "wa" { + t.Fatalf("unsplit: layout %q project %q active %q, want wa alone", id, project, active) + } + if tabs, _ := m.stripTabs(); len(tabs) != 2 { + t.Fatalf("the layout tab stayed: %+v", tabs) + } + m.splitBeside(ctx, "orch", "wb", edgeRight, false) + if _, active, ok := m.closeSession("wb"); !ok || active != "orch" { + t.Fatalf("closing the focused pane left %q on screen, want orch", active) + } + if id, project, _ := layoutScreen(t, m); id != "" || project != "lich" { + t.Fatalf("closed session: layout %q project %q, want orch alone in lich", id, project) + } +} + +func TestReloadDropsPanesOfSessionsGone(t *testing.T) { + m, b := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + b.last(t, 1) + m.mu.Lock() + m.projects[1].Sessions = m.projects[1].Sessions[1:] + pruned := m.layouts.prune(m.liveLocked) + m.mu.Unlock() + if !pruned { + t.Fatal("prune kept a session no project holds") + } + if lt, ok := m.shownLayoutTab(); ok { + t.Fatalf("a layout left with one pane stayed: %+v", lt) + } +} + +func TestCyclePaneAndZoomFollowTheLayoutOnScreen(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + m.splitBeside(ctx, "wa", "wb", edgeBottom, false) + m.cyclePane(ctx, 1) + if _, _, active := layoutScreen(t, m); active != "orch" { + t.Fatalf("cycle from the last pane landed on %q, want the first", active) + } + m.toggleZoom() + if lt, _ := m.shownLayoutTab(); !lt.Zoomed { + t.Fatal("zoom did not take") + } +} + +func TestMoveLayoutReordersOnlyAmongLayouts(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + m.splitBeside(ctx, "notes", "wb", edgeRight, false) + m.mu.Lock() + first, second := m.layouts.tabs[0].ID, m.layouts.tabs[1].ID + m.mu.Unlock() + if m.moveLayout(first, "lich") { + t.Fatal("a layout moved among the projects") + } + if !m.moveLayout(first, second) { + t.Fatal("layouts did not reorder") + } + m.mu.Lock() + defer m.mu.Unlock() + if m.layouts.tabs[0].ID != second { + t.Fatalf("order %s, %s", m.layouts.tabs[0].ID, m.layouts.tabs[1].ID) + } +} + +func TestRenameLayoutNamesItsTabAndIsStored(t *testing.T) { + m, b := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + b.last(t, 1) + id, _, _ := layoutScreen(t, m) + if !m.renameLayout(id, "review") || m.renameLayout("missing", "x") { + t.Fatal("rename reported the wrong layouts") + } + m.saveLayouts() + saved, err := parseLayouts(b.last(t, 2)) + if err != nil || saved.tabs[0].Name != "review" { + t.Fatalf("stored %+v, %v", saved.tabs, err) + } + if tabs, _ := m.stripTabs(); tabs[len(tabs)-1].Name != "review" { + t.Fatalf("strip %+v", tabs) + } +} + +func TestMovePaneRearrangesOneLayoutOnly(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + m.splitBeside(ctx, "wa", "wb", edgeRight, false) + if !m.movePane(ctx, "wb", "orch", edgeBottom) { + t.Fatal("move refused") + } + lt, _ := m.shownLayoutTab() + if got, want := paneShape(lt.Root), "row(column(orch,wb),wa)"; got != want { + t.Fatalf("got %s, want %s", got, want) + } + if _, _, active := layoutScreen(t, m); active != "wb" { + t.Fatalf("focus %q, want the pane moved", active) + } + if m.movePane(ctx, "notes", "orch", edgeLeft) { + t.Fatal("moved a session that is in no layout") + } + if !m.rotateFocused() { + t.Fatal("rotate refused") + } + lt, _ = m.shownLayoutTab() + if got, want := paneShape(lt.Root), "row(row(orch,wb),wa)"; got != want { + t.Fatalf("after rotate got %s, want %s", got, want) + } +} + +func TestShowOtherThanNeverLeavesTheDraggedSessionAloneOnScreen(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "notes", "wa", edgeRight, false) + m.setActiveProject("lich") + m.mu.Lock() + m.active, m.shownLayout = "orch", "" + m.mu.Unlock() + m.showOtherThan(ctx, "notes") + if _, _, active := layoutScreen(t, m); active != "orch" { + t.Fatalf("active %q, want orch left alone: it is not the dragged session", active) + } + m.mu.Lock() + m.active = "notes" + m.mu.Unlock() + m.showOtherThan(ctx, "notes") + if id, _, active := layoutScreen(t, m); id != "" || active != "orch" { + t.Fatalf("layout %q active %q, want orch, the session in no layout", id, active) + } +} diff --git a/native/cmd/lich-native/palette.go b/native/cmd/lich-native/palette.go index c5adb6ea..07327741 100644 --- a/native/cmd/lich-native/palette.go +++ b/native/cmd/lich-native/palette.go @@ -84,6 +84,9 @@ type commandPalette struct { dropped map[string]bool // picked is the actions Enter chose, run once the frame is over. picked []hotkeyID + // beside is the pane a picked session splits, while the palette is + // choosing one; nil for the plain switcher. + beside *besideRequest // last is the list the last open frame drew, which the closing fade keeps // drawing. last paletteView @@ -122,9 +125,23 @@ func (p *commandPalette) show(turns map[string]string) { p.reset() } +// besideRequest is a split waiting on its session: target's pane, the edge +// the picked session goes against. +type besideRequest struct { + target string + edge paneEdge +} + +// showBeside opens the palette on the sessions to pick the one that splits +// target's pane against edge. +func (p *commandPalette) showBeside(turns map[string]string, target string, edge paneEdge) { + p.show(turns) + p.tab, p.beside = tabSessions, &besideRequest{target: target, edge: edge} +} + // hide closes the palette; the next one opens on the plain list again. func (p *commandPalette) hide() { - p.open = false + p.open, p.beside = false, nil p.reset() } diff --git a/native/cmd/lich-native/palette_window.go b/native/cmd/lich-native/palette_window.go index b46958b3..51cc46f4 100644 --- a/native/cmd/lich-native/palette_window.go +++ b/native/cmd/lich-native/palette_window.go @@ -149,14 +149,19 @@ func (u *window) paletteFrame(gtx C, p *commandPalette) paletteView { func (u *window) runPaletteRow(r paletteRow) { p := u.palette switch r.kind { - case rowSession: - p.hide() - u.settings.hide() - u.openSession(r.session.projectID, r.session.sessionID) - case rowMessage: + case rowSession, rowMessage: + id, projectID := r.session.sessionID, r.session.projectID + if r.kind == rowMessage { + id, projectID = r.message.sessionID, r.message.projectID + } + beside := p.beside p.hide() u.settings.hide() - u.openSession(r.message.projectID, r.message.sessionID) + if beside != nil { + u.besidePicked(*beside, id) + return + } + u.openSession(projectID, id) case rowProject: p.hide() u.settings.hide() diff --git a/native/cmd/lich-native/projects.go b/native/cmd/lich-native/projects.go index caef9a90..0de9b4cb 100644 --- a/native/cmd/lich-native/projects.go +++ b/native/cmd/lich-native/projects.go @@ -34,6 +34,8 @@ type projectTab struct { // status is the badge, statusNone on the active tab: its cards are on // screen saying the same, per session (ProjectTab.tsx). status string + // layout marks a split layout's tab, whose Path lists its sessions. + layout bool } // projectTabs is the strip: every open project in order with its badge, and @@ -70,7 +72,7 @@ func sessionIDs(p lichclient.Project) []string { func (m *model) showHome() { m.mu.Lock() defer m.mu.Unlock() - m.home = true + m.home, m.shownLayout = true, "" if p := m.projectByID(m.homeID); p != nil { m.active = p.ActiveSessionID } diff --git a/native/cmd/lich-native/sidebar_block.go b/native/cmd/lich-native/sidebar_block.go index b892c76d..276a6d98 100644 --- a/native/cmd/lich-native/sidebar_block.go +++ b/native/cmd/lich-native/sidebar_block.go @@ -199,7 +199,10 @@ func (s *sidebar) blockCards(gtx C, th *ui.Theme, b blockView, st *blockState, f keys := st.presence.Update(gtx.Now, blockIDs(b)) keys = slices.DeleteFunc(keys, func(id string) bool { _, ok := views[id]; return !ok }) gtx.Constraints.Min.X = gtx.Constraints.Max.X - d := st.cards.List(gtx, ui.SortList{}, keys, func(gtx C, i int) D { + // A card carried out of the sidebar goes back to its slot; the window + // draws what follows the pointer over the panes (drawCarried). + list := ui.SortList{Leaves: st.cards.Carry != nil} + d := st.cards.List(gtx, list, keys, func(gtx C, i int) D { return s.blockCard(gtx, th, st, views[keys[i]], f, dragEnded) }) if len(keys) > 0 { diff --git a/native/cmd/lich-native/sidebar_card.go b/native/cmd/lich-native/sidebar_card.go index 0103eab9..1ed33c63 100644 --- a/native/cmd/lich-native/sidebar_card.go +++ b/native/cmd/lich-native/sidebar_card.go @@ -13,6 +13,7 @@ import ( "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" + "gioui.org/unit" "gioui.org/widget" "github.com/lichdotdev/lich/native/ui" ) @@ -102,6 +103,44 @@ func (s *sidebar) cardFace(gtx C, th *ui.Theme, sv *sessionView, click, pinClick }) } +// inHand is a session carried over the window, a card out of the sidebar +// or a pane by its header: a pill with its ring and name just right of the +// pointer at p, or left of it where the window ends first. +func (s *sidebar) inHand(gtx C, th *ui.Theme, sv *sessionView, p image.Point) { + size := gtx.Constraints.Max + on := th.On(th.Popover) + m := op.Record(gtx.Ops) + cgtx := gtx + cgtx.Constraints = layout.Constraints{Max: image.Pt(gtx.Dp(cardInHandWidth), size.Y)} + pill := layout.Inset{Left: ui.Space(1), Right: ui.Space(3), Top: ui.Space(1), Bottom: ui.Space(1)}.Layout(cgtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return s.statusGlyph(gtx, on, sv) }), + ui.Gap(1.5), + layout.Rigid(on.Text(ui.TextSM, sv.Label).Weight(font.Medium).Layout), + ) + }) + body := m.Stop() + gap := gtx.Dp(cardInHandGap) + x := p.X + gap + if x+pill.Size.X > size.X { + x = p.X - gap - pill.Size.X + } + at := image.Pt(x, p.Y-pill.Size.Y/2) + defer op.Offset(at).Push(gtx.Ops).Pop() + shape := clip.UniformRRect(image.Rectangle{Max: pill.Size}, pill.Size.Y/2) + paint.FillShape(gtx.Ops, th.Popover, shape.Op(gtx.Ops)) + paint.FillShape(gtx.Ops, th.Ring, clip.Stroke{Path: shape.Path(gtx.Ops), Width: float32(gtx.Dp(1))}.Op()) + body.Add(gtx.Ops) +} + +// The pill a carried session becomes sits cardInHandGap right of the +// pointer, clear of the cursor, or as far left of it where the window ends +// first, its name cut short past cardInHandWidth. +var ( + cardInHandGap = unit.Dp(14) + cardInHandWidth = unit.Dp(240) +) + func (s *sidebar) renaming(id string) bool { rename := s.renames[id] return rename != nil && rename.Active() diff --git a/native/cmd/lich-native/stage_layout.go b/native/cmd/lich-native/stage_layout.go new file mode 100644 index 00000000..b6118baf --- /dev/null +++ b/native/cmd/lich-native/stage_layout.go @@ -0,0 +1,475 @@ +package main + +import ( + "errors" + "image" + "image/color" + + "gioui.org/font" + "gioui.org/io/event" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" + "gioui.org/widget" + + "github.com/lichdotdev/lich/native/gioterm" + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" + "github.com/lichdotdev/lich/native/ui/icons" +) + +// A layout on the island: its tree of panes, each a session's terminal under +// a header naming it, with draggable gaps between them. + +// The smallest pane a split leaves. 480dp is 51 columns at the default 13sp, +// where Claude Code's and Codex's dialogs still wrap cleanly (measured +// 2026-10-17); a larger terminal font holds fewer. +var ( + paneMinWidth = unit.Dp(480) + paneMinHeight = unit.Dp(180) + paneHeader = ui.Space(8) + paneRadius = ui.RadiusLG + // paneDim is how far an unfocused pane's terminal fades toward the + // island, so the one that takes the keys reads first. + paneDim = float32(0.35) +) + +var ( + icPaneZoom = icons.Lucide("maximize-2") + icPaneUnzoom = icons.Lucide("minimize-2") + icPaneUnsplit = icons.Lucide("x") +) + +// paneUI is one pane's widgets, kept across frames by session id. +type paneUI struct { + zoom, unsplit, chip widget.Clickable + zoomTip, unsplitTip ui.Tooltip +} + +// paneDivider is the gap between a split's two sides, dragged to move the +// ratio; ratio holds the drag's value until it is stored on release. +type paneDivider struct { + dragging bool + press float32 + startRatio float64 + ratio float64 +} + +// layoutStage is the window's state for the layout on screen. +type layoutStage struct { + panes map[string]*paneUI + dividers map[string]*paneDivider + // metric is the island's, which the smallest pane is measured in. + metric unit.Metric + move moveConfirm + pointer dragPointer + // focus, zoom and unsplit are what this frame's clicks asked of a pane; + // drop is a pane dragged by its header onto a drop zone. + focus, unsplit string + zoom bool + drop ui.SortDrop + // switchTab is a strip tab something carried rested on, to bring on + // screen. + switchTab string + // sort drags the panes by their headers, over the sidebar's carry. + sort ui.Sortable +} + +func (s *layoutStage) pane(id string) *paneUI { + if s.panes == nil { + s.panes = map[string]*paneUI{} + } + p, ok := s.panes[id] + if !ok { + p = new(paneUI) + s.panes[id] = p + } + return p +} + +func (s *layoutStage) divider(key string) *paneDivider { + if s.dividers == nil { + s.dividers = map[string]*paneDivider{} + } + d, ok := s.dividers[key] + if !ok { + d = new(paneDivider) + s.dividers[key] = d + } + return d +} + +// layoutIsland draws the layout on screen over the island's area. +func (u *window) layoutIsland(gtx C, v stageView) error { + st := &u.layoutStage + st.metric = gtx.Metric + st.sort.Carry = &u.sidebar.carry + // The drop comes from readDragPointer, which knows the zone under the + // pointer; the Sortable only carries the pane. + st.sort.Update(gtx) + lt := v.layout + ids := lt.sessions() + root := lt.Root + if lt.Zoomed { + root = &paneNode{Session: lt.Focus} + } + u.terms.onScreen(onScreenOf(root)...) + in := image.Rectangle{Max: gtx.Constraints.Max}.Inset(gtx.Dp(gutter)) + return u.paneTree(gtx, v, root, "", in, len(ids) > 1) +} + +func onScreenOf(n *paneNode) []string { + t := layoutTab{Root: n} + return t.sessions() +} + +// paneTree draws node n into r. path names the node for its divider: "" the +// root, then one digit per step, 0 first and 1 second. +func (u *window) paneTree(gtx C, v stageView, n *paneNode, path string, r image.Rectangle, several bool) error { + if n.leaf() { + return u.paneAt(gtx, v, n.Session, r, several) + } + first, second, gap := u.splitRects(gtx, v, n, path, r) + if err := u.paneTree(gtx, v, n.First, path+"0", first, several); err != nil { + return err + } + if err := u.paneTree(gtx, v, n.Second, path+"1", second, several); err != nil { + return err + } + u.dividerAt(gtx, v.layout.ID, n, path, r, gap) + return nil +} + +// splitRects divides r between n's sides at its ratio, the one being dragged +// while a drag is on, keeping each side its smallest size while r has room. +func (u *window) splitRects(gtx C, v stageView, n *paneNode, path string, r image.Rectangle) (first, second, gap image.Rectangle) { + ratio := n.Ratio + if d := u.layoutStage.divider(v.layout.ID + "/" + path); d.dragging { + ratio = d.ratio + } + g := gtx.Dp(gutter) + fw, fh := minSize(n.First, gtx.Dp(paneMinWidth), gtx.Dp(paneMinHeight), g) + sw, sh := minSize(n.Second, gtx.Dp(paneMinWidth), gtx.Dp(paneMinHeight), g) + span, lo, hi := r.Dy()-g, fh, sh + if n.Axis == splitRow { + span, lo, hi = r.Dx()-g, fw, sw + } + at := int(ratio * float64(span)) + if lo+hi <= span { + at = min(max(at, lo), span-hi) + } + if n.Axis == splitRow { + x := r.Min.X + at + return image.Rect(r.Min.X, r.Min.Y, x, r.Max.Y), image.Rect(x+g, r.Min.Y, r.Max.X, r.Max.Y), image.Rect(x, r.Min.Y, x+g, r.Max.Y) + } + y := r.Min.Y + at + return image.Rect(r.Min.X, r.Min.Y, r.Max.X, y), image.Rect(r.Min.X, y+g, r.Max.X, r.Max.Y), image.Rect(r.Min.X, y, r.Max.X, y+g) +} + +// dividerAt makes the gap between n's sides draggable, storing the ratio it +// was let go at. +func (u *window) dividerAt(gtx C, layoutID string, n *paneNode, path string, r, gap image.Rectangle) { + d := u.layoutStage.divider(layoutID + "/" + path) + span := float32(r.Dy() - gap.Dy()) + if n.Axis == splitRow { + span = float32(r.Dx() - gap.Dx()) + } + for { + ev, ok := gtx.Event(pointer.Filter{Target: d, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel}) + if !ok { + break + } + e, ok := ev.(pointer.Event) + if !ok || span <= 0 { + continue + } + at := e.Position.Y + if n.Axis == splitRow { + at = e.Position.X + } + switch e.Kind { + case pointer.Press: + d.dragging, d.press, d.startRatio, d.ratio = true, at, n.Ratio, n.Ratio + case pointer.Drag: + if d.dragging { + d.ratio = clampRatio(d.startRatio + float64((at-d.press)/span)) + } + case pointer.Release: + if d.dragging { + d.dragging = false + u.model.setRatio(layoutID, pathSteps(path), d.ratio) + } + case pointer.Cancel: + d.dragging = false + } + } + defer clip.Rect(gap).Push(gtx.Ops).Pop() + cursor := pointer.CursorRowResize + if n.Axis == splitRow { + cursor = pointer.CursorColResize + } + cursor.Add(gtx.Ops) + event.Op(gtx.Ops, d) +} + +func pathSteps(path string) []bool { + steps := make([]bool, len(path)) + for i := range path { + steps[i] = path[i] == '1' + } + return steps +} + +// paneAt draws session id's pane in r: its header, its terminal, and with +// more than one pane the focus ring or the fade. +func (u *window) paneAt(gtx C, v stageView, id string, r image.Rectangle, several bool) error { + sv, ok := v.panes[id] + if !ok { + return nil + } + defer op.Offset(r.Min).Push(gtx.Ops).Pop() + gtx.Constraints = layout.Exact(r.Size()) + size := r.Size() + rr := gtx.Dp(paneRadius) + focused := id == v.layout.Focus + bg := u.terms.backgroundOf(id) + if bg.A == 0 { + bg = u.theme.Background + } + shape := clip.UniformRRect(image.Rectangle{Max: size}, rr) + paint.FillShape(gtx.Ops, bg, shape.Op(gtx.Ops)) + area := shape.Push(gtx.Ops) + on := u.theme.On(bg) + head := gtx.Dp(paneHeader) + header := gtx + header.Constraints = layout.Exact(image.Pt(size.X, head)) + u.layoutStage.sort.Item(header, id, func(gtx C) D { + u.paneHeader(gtx, on, v, sv, focused) + return D{Size: gtx.Constraints.Max} + }, nil) + paint.FillShape(gtx.Ops, on.Over(on.Border), clip.Rect{Min: image.Pt(0, head-gtx.Dp(1)), Max: image.Pt(size.X, head)}.Op()) + body := gtx + body.Constraints = layout.Exact(image.Pt(size.X, max(size.Y-head, 0))) + off := op.Offset(image.Pt(0, head)).Push(gtx.Ops) + err := u.content(body, sv, focused && u.terminalTakesKeys(gtx)) + if focused { + u.paneExit(body, sv) + u.paneDrop(body, on, sv, r.Min.Add(image.Pt(0, head))) + } + if several && !focused { + paint.FillShape(gtx.Ops, ui.Alpha(u.theme.Background, paneDim), clip.Rect{Max: body.Constraints.Max}.Op()) + } + off.Pop() + u.paneFocusTarget(gtx, id) + area.Pop() + u.paneDropZones(gtx, id, image.Rectangle{Max: size}, r.Add(u.islandAt), false) + border, width := on.Over(on.Border), float32(gtx.Dp(1)) + if several && focused { + border, width = u.theme.Ring, float32(gtx.Dp(1.5)) + } + paint.FillShape(gtx.Ops, border, clip.Stroke{Path: shape.Path(gtx.Ops), Width: width}.Op()) + return err +} + +// paneExit is the exit band of the focused pane's terminal once its process +// is gone. ponytail: only the focused pane shows it, the band's state being +// the window's one; a pane in the background shows its last frame. +func (u *window) paneExit(gtx C, sv sessionView) { + e, ok := u.terms.exitOf(sv.ID) + if !ok { + return + } + restart, closing := u.exitBand.layout(gtx, u.theme, e) + if restart { + go u.terms.restart(u.ctx, sv) + } + if closing { + u.closeSession(sv.ID) + } +} + +// paneDrop takes a file dragged over the focused pane's terminal, at at in +// the island, which is where the active session's prompt is. +func (u *window) paneDrop(gtx C, th *ui.Theme, sv sessionView, at image.Point) { + if !u.terms.has(sv.ID) { + return + } + corner := u.islandAt.Add(at) + u.drop.setRect(image.Rectangle{Min: corner, Max: corner.Add(gtx.Constraints.Max)}) + if u.drop.hinting() || *flagDropHint { + dropHint(gtx, th, sv.Label, sv.sandbox.Confined) + } +} + +// paneFocusTarget hears a press anywhere on the pane, passing it on to the +// terminal under it, and focuses the pane. +func (u *window) paneFocusTarget(gtx C, id string) { + p := u.layoutStage.pane(id) + for { + ev, ok := gtx.Event(pointer.Filter{Target: p, Kinds: pointer.Press}) + if !ok { + break + } + if e, ok := ev.(pointer.Event); ok && e.Kind == pointer.Press { + u.layoutStage.focus = id + } + } + defer pointer.PassOp{}.Push(gtx.Ops).Pop() + defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() + event.Op(gtx.Ops, p) +} + +// paneHeader is the pane's top row: its ring, its project when the layout +// spans several, its name and checkout, and its zoom and unsplit buttons, or +// while zoomed, the chip that tells what the zoom hides. +func (u *window) paneHeader(gtx C, th *ui.Theme, v stageView, sv sessionView, focused bool) { + p := u.layoutStage.pane(sv.ID) + if p.zoom.Clicked(gtx) || p.chip.Clicked(gtx) { + u.layoutStage.focus, u.layoutStage.zoom = sv.ID, true + } + if p.unsplit.Clicked(gtx) { + u.layoutStage.unsplit = sv.ID + } + fg, weight := th.MutedForeground, font.Normal + if focused { + fg, weight = th.Foreground, font.Medium + } + layout.Inset{Left: ui.Space(1), Right: ui.Space(2)}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return u.sidebar.statusGlyph(gtx, th, &sv) }), + ui.Gap(1.5), + layout.Rigid(func(gtx C) D { + if !v.mixed { + return D{} + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return projectTag(gtx, th, v.projectNames[sv.projectID], fg) }), ui.Gap(1.5)) + }), + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(th.Text(ui.TextXS, sv.Label).In(fg).Weight(weight).Layout), + ui.Gap(2), + layout.Flexed(1, th.Text(ui.TextXS, worktreeLabel(sv, v.projectPaths[sv.projectID])).Muted().Mono().Layout), + ) + }), + layout.Rigid(func(gtx C) D { return u.paneButtons(gtx, th, v, sv, p) }), + ) + }) +} + +// paneButtons is the header's right end: zoom and unsplit, or the zoom chip. +func (u *window) paneButtons(gtx C, th *ui.Theme, v stageView, sv sessionView, p *paneUI) D { + if v.layout.Zoomed { + return p.chip.Layout(gtx, func(gtx C) D { return zoomChip(gtx, th, v) }) + } + zoom := th.Tooltip(&u.root, &p.zoomTip, i18n.T("layouts.pane.zoom")) + unsplit := th.Tooltip(&u.root, &p.unsplitTip, i18n.T("layouts.pane.unsplit")) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return zoom.Layout(gtx, func(gtx C) D { return cornerButton(gtx, th, &p.zoom, icPaneZoom, false) }) + }), + ui.Gap(1.5), + layout.Rigid(func(gtx C) D { + return unsplit.Layout(gtx, func(gtx C) D { return cornerButton(gtx, th, &p.unsplit, icPaneUnsplit, false) }) + }), + ) +} + +// zoomChip names what the zoom hides: how many panes, and how many of them +// wait on the user. +func zoomChip(gtx C, th *ui.Theme, v stageView) D { + hidden, waiting := 0, 0 + for _, id := range v.layout.sessions() { + if id == v.layout.Focus { + continue + } + hidden++ + if v.panes[id].status == statusWaiting { + waiting++ + } + } + text := i18n.T("layouts.pane.hidden", i18n.Args{"count": hidden}) + if waiting > 0 { + text = i18n.T("layouts.pane.hiddenWaiting", i18n.Args{"count": hidden, "waiting": waiting}) + } + return pill(gtx, th, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return icPaneUnzoom.Layout(gtx, 12, th.MutedForeground) }), + ui.Gap(1.5), + layout.Rigid(th.Text(ui.TextXS, text).Muted().Layout), + ) + }, ui.Space(2), ui.Space(2)) +} + +// projectTag is the project's name in an outlined pill. +func projectTag(gtx C, th *ui.Theme, name string, fg color.NRGBA) D { + return pill(gtx, th, th.Text(ui.TextXS, name).In(fg).Layout, ui.Space(2), ui.Space(2)) +} + +// worktreeLabel names the worktree a pane's session runs in, "" for the +// project's own checkout: the project already names that one. +func worktreeLabel(sv sessionView, projectPath string) string { + if checkoutPath(sv.path, projectPath) == "" { + return "" + } + return checkoutLabel(sv.path, projectPath, sv.projectID) +} + +// applyLayoutStage carries out what the frame's clicks asked of the panes. +func (u *window) applyLayoutStage() { + st := &u.layoutStage + focus, zoom, unsplit, drop := st.focus, st.zoom, st.unsplit, st.drop + st.focus, st.zoom, st.unsplit, st.drop = "", false, "", ui.SortDrop{} + if id := st.switchTab; id != "" { + st.switchTab = "" + u.settings.hide() + u.pulls.hide() + u.showStripTab(id) + if carried := u.sidebar.carry.Carrying(); carried != "" { + u.model.showOtherThan(u.ctx, carried) + } + } + if drop.ID != "" { + u.dropOnPane(drop.ID, drop.Over) + } + if focus != "" { + if active, ok := u.model.activeSession(); !ok || active.ID != focus { + u.model.setActive(u.ctx, focus) + } + } + if zoom { + u.model.toggleZoom() + } + if unsplit != "" { + u.model.unsplit(u.ctx, unsplit) + } + if focus != "" || zoom || unsplit != "" { + u.model.invalidate() + } +} + +// content shows session s's terminal over gtx's area, keys typing into it +// when it takes them. +func (u *window) content(gtx C, s sessionView, keys bool) error { + resume, ok := "", u.terms.has(s.ID) + if !ok { + resume, ok = u.gate.release(u.ctx, s) + } + if !ok { + return nil + } + t, err := u.terms.show(u.ctx, gtx, s, resume) + if err != nil { + return err + } + err = u.terms.layout(gtx, t, keys) + // A reconnect frees every terminal off the frame goroutine; one shown + // since is replaced on the next frame, which the window must survive. + if errors.Is(err, gioterm.ErrClosed) { + gtx.Execute(op.InvalidateCmd{}) + return nil + } + return err +} diff --git a/native/cmd/lich-native/terminals.go b/native/cmd/lich-native/terminals.go index 00dbcb10..7c16d811 100644 --- a/native/cmd/lich-native/terminals.go +++ b/native/cmd/lich-native/terminals.go @@ -7,6 +7,7 @@ import ( "log" "maps" "runtime" + "slices" "sync" "time" @@ -50,6 +51,9 @@ type termView struct { pointerIn bool // exit is set once the process in the PTY is gone, until a restart. exit *sessionExit + // background is the default background the grid last drew, under + // terminals.mu. + background color.NRGBA } // terminalPadding is term-view.ts TERMINAL_PADDING, in the terminal's own @@ -93,14 +97,13 @@ type terminals struct { // the interface zoom: a zoom step never changes a grid's cols×rows. unzoomed unit.Metric - mu sync.Mutex - byID map[string]*termView - shown string + mu sync.Mutex + byID map[string]*termView + // shown are the sessions whose terminals the last frame drew, which the + // backend streams output for without coalescing. + shown map[string]bool // colors are the theme's terminal defaults, given to every terminal. colors gioterm.Colors - // background is the shown terminal's default background as last drawn, - // the color of the island it fills and of the tab merged into it. - background color.NRGBA // typeahead is input for a session whose terminal has not started yet, // sent right after Start; the PTY holds it until the shell reads it. typeahead map[string]string @@ -158,13 +161,13 @@ func (ts *terminals) start(ctx context.Context, s sessionView, resume string, co func (ts *terminals) onOutput(id string, data []byte) { ts.mu.Lock() - t, shown := ts.byID[id], ts.shown + t, shown := ts.byID[id], ts.shown[id] ts.mu.Unlock() if t == nil { return } t.vt.Write(data) - if id == shown { + if shown { ts.invalidate() } } @@ -203,12 +206,8 @@ func (ts *terminals) has(id string) bool { // the view is dropped; the page subscribes before replaying instead. func (ts *terminals) show(ctx context.Context, gtx layout.Context, s sessionView, resume string) (*termView, error) { ts.mu.Lock() - t, previous := ts.byID[s.ID], ts.shown - ts.shown = s.ID + t := ts.byID[s.ID] ts.mu.Unlock() - if previous != s.ID { - go ts.setVisible(previous, s.ID) - } if t != nil { return t, nil } @@ -305,7 +304,7 @@ func (ts *terminals) setFont(family string, size unit.Sp) error { func (ts *terminals) reattach(stream *lichclient.Stream) { ts.mu.Lock() views := ts.byID - ts.stream, ts.byID, ts.shown = stream, map[string]*termView{}, "" + ts.stream, ts.byID, ts.shown = stream, map[string]*termView{}, nil ts.mu.Unlock() for _, t := range views { t.vt.Close() @@ -318,25 +317,61 @@ func (ts *terminals) drop(id string) { ts.mu.Lock() t := ts.byID[id] delete(ts.byID, id) - if ts.shown == id { - ts.shown = "" - } + delete(ts.shown, id) ts.mu.Unlock() if t != nil { t.vt.Close() } } -func (ts *terminals) setVisible(hidden, shown string) { +// onScreen records the sessions this frame draws a terminal for, and tells +// the backend about each that came on or went off screen. +func (ts *terminals) onScreen(ids ...string) { + ts.mu.Lock() + var hidden, shown []string + for id := range ts.shown { + if !slices.Contains(ids, id) { + hidden = append(hidden, id) + } + } + next := make(map[string]bool, len(ids)) + for _, id := range ids { + if !ts.shown[id] { + shown = append(shown, id) + } + next[id] = true + } + ts.shown = next + ts.mu.Unlock() + if len(hidden)+len(shown) > 0 { + go ts.setVisible(hidden, shown) + } +} + +func (ts *terminals) setVisible(hidden, shown []string) { ctx := context.Background() - if hidden != "" { - if err := ts.client.SetVisible(ctx, hidden, false); err != nil { - log.Printf("hide %s: %v", hidden, err) + for _, id := range hidden { + if err := ts.client.SetVisible(ctx, id, false); err != nil { + log.Printf("hide %s: %v", id, err) } } - if err := ts.client.SetVisible(ctx, shown, true); err != nil { - log.Printf("show %s: %v", shown, err) + for _, id := range shown { + if err := ts.client.SetVisible(ctx, id, true); err != nil { + log.Printf("show %s: %v", id, err) + } + } +} + +// backgroundOf is session id's terminal default background as last drawn, +// transparent before it ever was: the color of the island it fills and of the +// tab merged into it. +func (ts *terminals) backgroundOf(id string) color.NRGBA { + ts.mu.Lock() + defer ts.mu.Unlock() + if t := ts.byID[id]; t != nil { + return t.background } + return color.NRGBA{} } // layout draws t into the whole of gtx.Constraints.Max, following the area's @@ -375,7 +410,9 @@ func (ts *terminals) layout(gtx layout.Context, t *termView, focus bool) error { if err != nil { return err } - ts.background = snap.Background() + ts.mu.Lock() + t.background = snap.Background() + ts.mu.Unlock() if _, err := t.r.Draw(gtx, snap); err != nil { return err } diff --git a/native/cmd/lich-native/topbar.go b/native/cmd/lich-native/topbar.go index 8e806f98..b859f4a3 100644 --- a/native/cmd/lich-native/topbar.go +++ b/native/cmd/lich-native/topbar.go @@ -49,8 +49,10 @@ type topbar struct { brand widget.Clickable sort ui.Sortable list layout.List - // tabW is the tabs' width as the strip last laid them out. + // tabW is the tabs' width as the strip last laid them out, and laid + // where each tab was drawn, in window pixels. tabW int + laid []laidTab // merge eases the active tab between detached and merged into merged, // the island it last grew out of; mergedFor is the tab it eases. merge dockSlide @@ -86,8 +88,13 @@ type topbarAction struct { settings bool projectSettings string pulls bool // the Pull requests button + // renamed is a layout tab's new name, committed this frame. + renamed layoutRename } +// layoutRename is a layout's new name. +type layoutRename struct{ id, name string } + // topbarFrame is what the window measured for the strip this frame. type topbarFrame struct { brandWidth int // the sidebar's column, rail to panel diff --git a/native/cmd/lich-native/topbar_apply.go b/native/cmd/lich-native/topbar_apply.go index cbb4169f..72fc10fb 100644 --- a/native/cmd/lich-native/topbar_apply.go +++ b/native/cmd/lich-native/topbar_apply.go @@ -66,15 +66,22 @@ func (u *window) applyTopbar(act topbarAction) { u.saveScreen("") } if act.show != "" { - u.showProject(act.show) + u.showStripTab(act.show) } if act.ask != "" { u.requestClose(act.ask) } + if r := act.renamed; r.id != "" && u.model.renameLayout(r.id, r.name) { + u.model.saveLayouts() + u.model.invalidate() + } if act.close != "" { u.closeProject(act.close) } - if d := act.drop; d.ID != "" { + if d := act.drop; d.ID != "" && u.model.moveLayout(d.ID, d.Over) { + u.model.saveLayouts() + u.model.invalidate() + } else if d.ID != "" { if ids := u.model.moveProject(d.ID, d.Over); ids != nil { u.model.invalidate() go func() { @@ -139,6 +146,15 @@ func (u *window) showProject(id string) { u.saveScreen(id) } +// showStripTab puts a strip tab on screen: a project, or a layout. +func (u *window) showStripTab(id string) { + if u.model.showLayout(u.ctx, id) { + u.model.invalidate() + return + } + u.showProject(id) +} + func (u *window) saveScreen(projectID string) { go func() { u.logErr("save last screen", saveLastScreen(u.ctx, u.client, projectID)) }() } @@ -146,6 +162,11 @@ func (u *window) saveScreen(projectID string) { // requestClose closes a project at once, or asks first while any of its // sessions is mid-turn. func (u *window) requestClose(id string) { + if u.model.isLayout(id) { + u.model.closeLayout(u.ctx, id) + u.model.invalidate() + return + } running := u.model.runningIn(id) if running == 0 { u.closeProject(id) diff --git a/native/cmd/lich-native/topbar_tabs.go b/native/cmd/lich-native/topbar_tabs.go index 4cf8b297..0b9abbc1 100644 --- a/native/cmd/lich-native/topbar_tabs.go +++ b/native/cmd/lich-native/topbar_tabs.go @@ -49,6 +49,7 @@ var ( icTabClose = icons.Lucide("x") icTabWaiting = icons.Lucide("bell") icTabDone = icons.Lucide("check") + icTabLayout = icons.Lucide("layout-panel-left") ) type tabState struct { @@ -56,6 +57,19 @@ type tabState struct { close widget.Clickable tip ui.Tooltip menu ui.MenuState + // rename is a layout tab's name as a field, while it is renamed. + rename ui.InlineEdit +} + +// holdsKeys reports whether a tab's name is being edited, which keeps the +// keyboard off the terminal. +func (t *topbar) holdsKeys() bool { + for _, st := range t.tabs { + if st.rename.Active() { + return true + } + } + return false } func (t *topbar) tabState(id string) *tabState { @@ -80,6 +94,8 @@ func tabWidth(room, count, widest, narrowest int) int { // strip is the scrolling row of tabs with the open-project button right after // the last one, and the fillets that merge the active tab into the island. func (t *topbar) strip(gtx C, th *ui.Theme, f topbarFrame, act *topbarAction) D { + // A tab being renamed takes presses for its field, not for a drag. + t.sort.Disabled = t.holdsKeys() before := t.sort.Dragging() if d, ok := t.sort.Update(gtx); ok && d.ID != d.Over { act.drop = d @@ -88,6 +104,7 @@ func (t *topbar) strip(gtx C, th *ui.Theme, f topbarFrame, act *topbarAction) D plus := gtx.Dp(barButton) + gtx.Dp(barGap) w := tabWidth(gtx.Constraints.Max.X-plus, len(f.tabs), gtx.Dp(tabWidthMax), gtx.Dp(tabWidthMin)) t.tabW = w + t.lay(gtx, f, w) ids := make([]string, len(f.tabs)) for i, tab := range f.tabs { ids[i] = tab.ID @@ -115,10 +132,49 @@ func (t *topbar) strip(gtx C, th *ui.Theme, f topbarFrame, act *topbarAction) D return D{Size: size} } +// laidTab is a strip tab and where it was drawn. +type laidTab struct { + id string + rect image.Rectangle +} + +// lay records where each tab of the strip, w wide and scrolled as the list +// is, sits in the window, cut to the strip's own width. +func (t *topbar) lay(gtx C, f topbarFrame, w int) { + t.laid = t.laid[:0] + top := gtx.Dp(tabTop) + strip := image.Rect(f.stripX, 0, f.stripX+gtx.Constraints.Max.X, gtx.Constraints.Max.Y) + for i, tab := range f.tabs { + x := f.stripX + i*w - t.list.Position.Offset + r := image.Rect(x, top, x+w, top+gtx.Dp(tabHeight)).Intersect(strip) + t.laid = append(t.laid, laidTab{id: tab.ID, rect: r}) + } +} + +// tabAt is the strip tab at window point p, "" for none. +func (t *topbar) tabAt(p image.Point) string { + for _, l := range t.laid { + if p.In(l.rect) { + return l.id + } + } + return "" +} + func (t *topbar) tab(gtx C, th *ui.Theme, f topbarFrame, i, w int, dragEnded bool, act *topbarAction) D { tab := f.tabs[i] st := t.tabState(tab.ID) - closed, clicked := st.close.Clicked(gtx), st.click.Clicked(gtx) + closed, clicked := st.close.Clicked(gtx), false + for { + c, ok := st.click.Update(gtx) + if !ok { + break + } + clicked = true + if c.NumClicks >= 2 && tab.layout { + st.rename.Start(tab.Name) + } + } switch { case closed: act.ask = tab.ID @@ -133,12 +189,19 @@ func (t *topbar) tab(gtx C, th *ui.Theme, f topbarFrame, i, w int, dragEnded boo {Kind: ui.MenuSeparator}, {Label: i18n.T("tabs.topbarTabs.closeProject"), Icon: icMenuClose, Variant: ui.VariantDestructive, OnClick: func() { act.ask = tab.ID }}, } + if tab.layout { + entries = []ui.MenuEntry{ + {Label: i18n.T("layouts.tab.rename"), Icon: icMenuRename, OnClick: func() { st.rename.Start(tab.Name) }}, + {Kind: ui.MenuSeparator}, + {Label: i18n.T("layouts.tab.close"), Icon: icMenuClose, OnClick: func() { act.ask = tab.ID }}, + } + } return t.sort.Item(gtx, tab.ID, func(gtx C) D { return th.ContextMenu(t.root, &st.menu, entries).Layout(gtx, func(gtx C) D { tip := th.TooltipCard(t.root, &st.tip, tabTooltip(th.On(th.Popover), tab)) tip.Side = ui.SideBottom return tip.Layout(gtx, func(gtx C) D { - return st.click.Layout(gtx, func(gtx C) D { return t.tabFace(gtx, th, f, i, st) }) + return st.click.Layout(gtx, func(gtx C) D { return t.tabFace(gtx, th, f, i, st, act) }) }) }) }, nil) @@ -147,7 +210,7 @@ func (t *topbar) tab(gtx C, th *ui.Theme, f topbarFrame, i, w int, dragEnded boo // tabFace draws a tab: the island's color when active, a faint fill when // hovered, the hairline before an inactive tab that follows another, then // the badge, the name and the ×, shown on hover and on the active tab. -func (t *topbar) tabFace(gtx C, th *ui.Theme, f topbarFrame, i int, st *tabState) D { +func (t *topbar) tabFace(gtx C, th *ui.Theme, f topbarFrame, i int, st *tabState, act *topbarAction) D { tab := f.tabs[i] active := tab.ID == f.active hovered := st.click.Hovered() || st.close.Hovered() @@ -179,7 +242,7 @@ func (t *topbar) tabFace(gtx C, th *ui.Theme, f topbarFrame, i int, st *tabState // tabs never moves one. gtx.Constraints = layout.Exact(image.Pt(size.X, size.Y-gtx.Dp(gutter))) layout.Inset{Left: tabPadLeft, Right: tabPadRight}.Layout(gtx, func(gtx C) D { - return layout.W.Layout(gtx, func(gtx C) D { return t.tabContent(gtx, on, tab, st, fg, weight, active || hovered) }) + return layout.W.Layout(gtx, func(gtx C) D { return t.tabContent(gtx, on, tab, st, fg, weight, active || hovered, act) }) }) return D{Size: size} } @@ -201,10 +264,25 @@ func detachedTab(gtx C, size image.Point, r int) clip.RRect { } // tabContent is the badge, the name and the × in a row. -func (t *topbar) tabContent(gtx C, on *ui.Theme, tab projectTab, st *tabState, fg color.NRGBA, weight font.Weight, showClose bool) D { +func (t *topbar) tabContent(gtx C, on *ui.Theme, tab projectTab, st *tabState, fg color.NRGBA, weight font.Weight, showClose bool, act *topbarAction) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + if !tab.layout { + return D{} + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return icTabLayout.Layout(gtx, tabGlyph, fg) }), ui.Gap(2)) + }), layout.Rigid(func(gtx C) D { return t.tabBadge(gtx, on, tab.status) }), - layout.Flexed(1, on.Text(tabTextSize, tab.Name).Leading(tabTextLeading).In(fg).Weight(weight).Layout), + layout.Flexed(1, func(gtx C) D { + if !st.rename.Active() { + return on.Text(tabTextSize, tab.Name).Leading(tabTextLeading).In(fg).Weight(weight).Layout(gtx) + } + d, res := on.InlineEdit(&st.rename, tabTextSize).Weight(weight).Layout(gtx) + if res.Outcome == ui.InlineEditCommitted { + act.renamed = layoutRename{id: tab.ID, name: res.Value} + } + return d + }), ui.Gap(2), layout.Rigid(func(gtx C) D { if !showClose { diff --git a/native/i18n/locales/en/hotkeys.json b/native/i18n/locales/en/hotkeys.json index 1381ce42..0608b175 100644 --- a/native/i18n/locales/en/hotkeys.json +++ b/native/i18n/locales/en/hotkeys.json @@ -23,7 +23,11 @@ "toggleDock": "Toggle the right dock", "searchInFiles": "Search in files", "splitBeside": "Show another session beside this one", + "splitBelow": "Show another session below this one", "otherPane": "Focus the next pane", + "zoomPane": "Zoom the focused pane", + "unsplitPane": "Take the focused pane out of its layout", + "rotateSplit": "Rotate the focused panes: side by side or stacked", "settings": "Settings", "pulls": "Pull requests", "shortcuts": "Keyboard shortcuts", diff --git a/native/i18n/locales/en/layouts.json b/native/i18n/locales/en/layouts.json new file mode 100644 index 00000000..807d72e5 --- /dev/null +++ b/native/i18n/locales/en/layouts.json @@ -0,0 +1,36 @@ +{ + "tab": { + "rename": "Rename layout", + "close": "Close layout" + }, + "pane": { + "zoom": "Zoom this pane", + "unsplit": "Take out of the layout", + "hidden": { + "one": "{count} more", + "other": "{count} more" + }, + "hiddenWaiting": { + "one": "{count} more · {waiting} waiting", + "other": "{count} more · {waiting} waiting" + } + }, + "split": { + "noRoom": "No room for another pane here. Make the window wider or take a pane out first." + }, + "move": { + "title": "Move {label} here?", + "body": "{label} is in the layout {layout}. Moving it takes it out of there; the session keeps running.", + "confirm": "Move" + }, + "load": { + "failed": "Could not read the saved layouts: {error}" + }, + "drop": { + "left": "Split left", + "right": "Split right", + "top": "Split above", + "bottom": "Split below", + "swap": "Swap" + } +} diff --git a/native/i18n/locales/es/hotkeys.json b/native/i18n/locales/es/hotkeys.json index b9126d02..bad0d5a8 100644 --- a/native/i18n/locales/es/hotkeys.json +++ b/native/i18n/locales/es/hotkeys.json @@ -23,7 +23,11 @@ "toggleDock": "Mostrar u ocultar el panel derecho", "searchInFiles": "Buscar en archivos", "splitBeside": "Mostrar otra sesión junto a esta", + "splitBelow": "Mostrar otra sesión debajo de esta", "otherPane": "Enfocar el siguiente panel", + "zoomPane": "Ampliar el panel enfocado", + "unsplitPane": "Sacar el panel enfocado de su diseño", + "rotateSplit": "Alternar la división enfocada entre lado a lado y apilada", "settings": "Ajustes", "pulls": "Pull requests", "shortcuts": "Atajos de teclado", diff --git a/native/i18n/locales/es/layouts.json b/native/i18n/locales/es/layouts.json new file mode 100644 index 00000000..8dbef5b7 --- /dev/null +++ b/native/i18n/locales/es/layouts.json @@ -0,0 +1,36 @@ +{ + "tab": { + "rename": "Renombrar diseño", + "close": "Cerrar diseño" + }, + "pane": { + "zoom": "Ampliar este panel", + "unsplit": "Sacar del diseño", + "hidden": { + "one": "{count} más", + "other": "{count} más" + }, + "hiddenWaiting": { + "one": "{count} más · {waiting} esperando", + "other": "{count} más · {waiting} esperando" + } + }, + "split": { + "noRoom": "No hay espacio para otro panel aquí. Ensancha la ventana o saca antes un panel." + }, + "move": { + "title": "¿Mover {label} aquí?", + "body": "{label} está en el diseño {layout}. Al moverla sale de allí; la sesión sigue en ejecución.", + "confirm": "Mover" + }, + "load": { + "failed": "No se pudieron leer los diseños guardados: {error}" + }, + "drop": { + "left": "Dividir a la izquierda", + "right": "Dividir a la derecha", + "top": "Dividir arriba", + "bottom": "Dividir abajo", + "swap": "Intercambiar" + } +} diff --git a/native/i18n/locales/ja/hotkeys.json b/native/i18n/locales/ja/hotkeys.json index deefcf8a..1c35d5cd 100644 --- a/native/i18n/locales/ja/hotkeys.json +++ b/native/i18n/locales/ja/hotkeys.json @@ -23,7 +23,11 @@ "toggleDock": "右ドックの表示を切り替え", "searchInFiles": "ファイル内を検索", "splitBeside": "このセッションの横に別のセッションを表示", + "splitBelow": "このセッションの下に別のセッションを表示", "otherPane": "次のペインにフォーカス", + "zoomPane": "フォーカス中のペインを拡大", + "unsplitPane": "フォーカス中のペインをレイアウトから外す", + "rotateSplit": "フォーカス中の分割を左右と上下で切り替え", "settings": "設定", "pulls": "プルリクエスト", "shortcuts": "キーボードショートカット", diff --git a/native/i18n/locales/ja/layouts.json b/native/i18n/locales/ja/layouts.json new file mode 100644 index 00000000..4fd16bc9 --- /dev/null +++ b/native/i18n/locales/ja/layouts.json @@ -0,0 +1,34 @@ +{ + "tab": { + "rename": "レイアウト名を変更", + "close": "レイアウトを閉じる" + }, + "pane": { + "zoom": "このペインを拡大", + "unsplit": "レイアウトから外す", + "hidden": { + "other": "ほか {count} 件" + }, + "hiddenWaiting": { + "other": "ほか {count} 件 · {waiting} 件待機中" + } + }, + "split": { + "noRoom": "ここには別のペインを置く余裕がありません。ウィンドウを広げるか、先にペインを外してください。" + }, + "move": { + "title": "{label} をここに移動しますか?", + "body": "{label} はレイアウト {layout} にあります。移動するとそこから外れますが、セッションは実行を続けます。", + "confirm": "移動" + }, + "load": { + "failed": "保存されたレイアウトを読み込めませんでした: {error}" + }, + "drop": { + "left": "左に分割", + "right": "右に分割", + "top": "上に分割", + "bottom": "下に分割", + "swap": "入れ替え" + } +} diff --git a/native/i18n/locales/ko/hotkeys.json b/native/i18n/locales/ko/hotkeys.json index 71c26b9e..c51b44af 100644 --- a/native/i18n/locales/ko/hotkeys.json +++ b/native/i18n/locales/ko/hotkeys.json @@ -23,7 +23,11 @@ "toggleDock": "오른쪽 독 전환", "searchInFiles": "파일 내용 검색", "splitBeside": "이 세션 옆에 다른 세션 표시", + "splitBelow": "이 세션 아래에 다른 세션 표시", "otherPane": "다음 창에 포커스", + "zoomPane": "포커스된 창 확대", + "unsplitPane": "포커스된 창을 레이아웃에서 빼기", + "rotateSplit": "포커스된 분할을 좌우와 위아래로 전환", "settings": "설정", "pulls": "풀 리퀘스트", "shortcuts": "키보드 단축키", diff --git a/native/i18n/locales/ko/layouts.json b/native/i18n/locales/ko/layouts.json new file mode 100644 index 00000000..1fd4f625 --- /dev/null +++ b/native/i18n/locales/ko/layouts.json @@ -0,0 +1,34 @@ +{ + "tab": { + "rename": "레이아웃 이름 바꾸기", + "close": "레이아웃 닫기" + }, + "pane": { + "zoom": "이 창 확대", + "unsplit": "레이아웃에서 빼기", + "hidden": { + "other": "{count}개 더" + }, + "hiddenWaiting": { + "other": "{count}개 더 · {waiting}개 대기 중" + } + }, + "split": { + "noRoom": "여기에는 창을 하나 더 놓을 공간이 없습니다. 창을 넓히거나 먼저 창 하나를 빼세요." + }, + "move": { + "title": "{label}을(를) 여기로 옮길까요?", + "body": "{label}은(는) {layout} 레이아웃에 있습니다. 옮기면 그곳에서 빠지며, 세션은 계속 실행됩니다.", + "confirm": "옮기기" + }, + "load": { + "failed": "저장된 레이아웃을 읽을 수 없습니다: {error}" + }, + "drop": { + "left": "왼쪽으로 분할", + "right": "오른쪽으로 분할", + "top": "위로 분할", + "bottom": "아래로 분할", + "swap": "바꾸기" + } +} diff --git a/native/i18n/locales/pt-br/hotkeys.json b/native/i18n/locales/pt-br/hotkeys.json index 890573dd..7bbc5da2 100644 --- a/native/i18n/locales/pt-br/hotkeys.json +++ b/native/i18n/locales/pt-br/hotkeys.json @@ -23,7 +23,11 @@ "toggleDock": "Mostrar ou ocultar o painel direito", "searchInFiles": "Buscar nos arquivos", "splitBeside": "Mostrar outra sessão ao lado desta", + "splitBelow": "Mostrar outra sessão abaixo desta", "otherPane": "Focar o próximo painel", + "zoomPane": "Ampliar o painel em foco", + "unsplitPane": "Tirar o painel em foco do layout", + "rotateSplit": "Alternar a divisão em foco entre lado a lado e empilhada", "settings": "Configurações", "pulls": "Pull requests", "shortcuts": "Atalhos de teclado", diff --git a/native/i18n/locales/pt-br/layouts.json b/native/i18n/locales/pt-br/layouts.json new file mode 100644 index 00000000..d7f565c6 --- /dev/null +++ b/native/i18n/locales/pt-br/layouts.json @@ -0,0 +1,36 @@ +{ + "tab": { + "rename": "Renomear layout", + "close": "Fechar layout" + }, + "pane": { + "zoom": "Ampliar este painel", + "unsplit": "Tirar do layout", + "hidden": { + "one": "mais {count}", + "other": "mais {count}" + }, + "hiddenWaiting": { + "one": "mais {count} · {waiting} esperando", + "other": "mais {count} · {waiting} esperando" + } + }, + "split": { + "noRoom": "Não há espaço para outro painel aqui. Alargue a janela ou tire um painel antes." + }, + "move": { + "title": "Mover {label} para cá?", + "body": "{label} está no layout {layout}. Mover tira a sessão de lá; ela continua rodando.", + "confirm": "Mover" + }, + "load": { + "failed": "Não foi possível ler os layouts salvos: {error}" + }, + "drop": { + "left": "Dividir à esquerda", + "right": "Dividir à direita", + "top": "Dividir acima", + "bottom": "Dividir abaixo", + "swap": "Trocar" + } +} diff --git a/native/i18n/locales/ru/hotkeys.json b/native/i18n/locales/ru/hotkeys.json index 8483b67c..8ced225b 100644 --- a/native/i18n/locales/ru/hotkeys.json +++ b/native/i18n/locales/ru/hotkeys.json @@ -23,7 +23,11 @@ "toggleDock": "Показать или скрыть правую панель", "searchInFiles": "Поиск в файлах", "splitBeside": "Показать другую сессию рядом с этой", + "splitBelow": "Показать другую сессию под этой", "otherPane": "Фокус на следующей панели", + "zoomPane": "Развернуть панель в фокусе", + "unsplitPane": "Убрать панель в фокусе из раскладки", + "rotateSplit": "Переключить разделение в фокусе: рядом или друг над другом", "settings": "Настройки", "pulls": "Pull requests", "shortcuts": "Сочетания клавиш", diff --git a/native/i18n/locales/ru/layouts.json b/native/i18n/locales/ru/layouts.json new file mode 100644 index 00000000..f0c77735 --- /dev/null +++ b/native/i18n/locales/ru/layouts.json @@ -0,0 +1,40 @@ +{ + "tab": { + "rename": "Переименовать раскладку", + "close": "Закрыть раскладку" + }, + "pane": { + "zoom": "Развернуть эту панель", + "unsplit": "Убрать из раскладки", + "hidden": { + "one": "ещё {count}", + "few": "ещё {count}", + "many": "ещё {count}", + "other": "ещё {count}" + }, + "hiddenWaiting": { + "one": "ещё {count} · ждут: {waiting}", + "few": "ещё {count} · ждут: {waiting}", + "many": "ещё {count} · ждут: {waiting}", + "other": "ещё {count} · ждут: {waiting}" + } + }, + "split": { + "noRoom": "Здесь нет места для ещё одной панели. Сделайте окно шире или сначала уберите панель." + }, + "move": { + "title": "Переместить {label} сюда?", + "body": "{label} находится в раскладке {layout}. После перемещения она уйдёт оттуда; сессия продолжит работу.", + "confirm": "Переместить" + }, + "load": { + "failed": "Не удалось прочитать сохранённые раскладки: {error}" + }, + "drop": { + "left": "Разделить слева", + "right": "Разделить справа", + "top": "Разделить сверху", + "bottom": "Разделить снизу", + "swap": "Поменять местами" + } +} diff --git a/native/i18n/locales/zh-cn/hotkeys.json b/native/i18n/locales/zh-cn/hotkeys.json index f9256f38..b3c3fb18 100644 --- a/native/i18n/locales/zh-cn/hotkeys.json +++ b/native/i18n/locales/zh-cn/hotkeys.json @@ -23,7 +23,11 @@ "toggleDock": "切换右侧停靠栏", "searchInFiles": "在文件中搜索", "splitBeside": "在此会话旁显示另一个会话", + "splitBelow": "在此会话下方显示另一个会话", "otherPane": "聚焦下一个窗格", + "zoomPane": "放大当前聚焦的窗格", + "unsplitPane": "将当前聚焦的窗格移出布局", + "rotateSplit": "在左右与上下之间切换当前聚焦的分割", "settings": "设置", "pulls": "拉取请求", "shortcuts": "键盘快捷键", diff --git a/native/i18n/locales/zh-cn/layouts.json b/native/i18n/locales/zh-cn/layouts.json new file mode 100644 index 00000000..84fd1db2 --- /dev/null +++ b/native/i18n/locales/zh-cn/layouts.json @@ -0,0 +1,34 @@ +{ + "tab": { + "rename": "重命名布局", + "close": "关闭布局" + }, + "pane": { + "zoom": "放大此窗格", + "unsplit": "移出布局", + "hidden": { + "other": "另外 {count} 个" + }, + "hiddenWaiting": { + "other": "另外 {count} 个 · {waiting} 个等待中" + } + }, + "split": { + "noRoom": "这里没有空间再放一个窗格。请加宽窗口,或先移出一个窗格。" + }, + "move": { + "title": "将 {label} 移到这里?", + "body": "{label} 在布局 {layout} 中。移动后会离开那里,会话继续运行。", + "confirm": "移动" + }, + "load": { + "failed": "无法读取已保存的布局:{error}" + }, + "drop": { + "left": "向左分割", + "right": "向右分割", + "top": "向上分割", + "bottom": "向下分割", + "swap": "交换" + } +} diff --git a/native/lichclient/api.go b/native/lichclient/api.go index 7a33ce39..ddc5b842 100644 --- a/native/lichclient/api.go +++ b/native/lichclient/api.go @@ -38,9 +38,11 @@ type Session struct { // Profile is the Claude Code profile the session runs in (a slug), "" for // Personal and for every other provider. Profile string `json:"profile"` - // OriginLabel is what the session this one came from was called when it - // did, "" for one nobody opened from another. - OriginLabel string `json:"originLabel"` + // OriginSessionID is the session this one came from, in any project, "" + // for one nobody opened from another; OriginLabel is what that session + // was called when it did. + OriginSessionID string `json:"originSessionId"` + OriginLabel string `json:"originLabel"` // Forked is whether the session was born branching that session's // conversation, rather than delegated or opened as a subagent. Forked bool `json:"forked"` diff --git a/native/lichclient/client_test.go b/native/lichclient/client_test.go index debfc8a6..d5c74ac9 100644 --- a/native/lichclient/client_test.go +++ b/native/lichclient/client_test.go @@ -64,6 +64,19 @@ func TestCallWithNoArgsSendsEmptyArray(t *testing.T) { } } +func TestLoadStateDecodesTheDelegatingSession(t *testing.T) { + c := fakeBackend(t, func(string, []json.RawMessage) (any, int) { + return json.RawMessage(`[{"id":"p","sessions":[{"id":"s2","originSessionId":"s1","originLabel":"orchestrator"}]}]`), http.StatusOK + }) + ps, err := c.LoadState(context.Background()) + if err != nil { + t.Fatal(err) + } + if s := ps[0].Sessions[0]; s.OriginSessionID != "s1" || s.OriginLabel != "orchestrator" { + t.Fatalf("got %+v", s) + } +} + func TestCallReturnsBackendError(t *testing.T) { c := fakeBackend(t, func(string, []json.RawMessage) (any, int) { return map[string]string{"error": "want 2 arguments, got 1"}, http.StatusBadRequest diff --git a/native/ui/sortable.go b/native/ui/sortable.go index c9757716..788d2f50 100644 --- a/native/ui/sortable.go +++ b/native/ui/sortable.go @@ -84,10 +84,15 @@ type sortTarget struct { func (s *Sortable) Update(gtx C) (SortDrop, bool) { if s.Carry != nil { s.Carry.hover(gtx) + if s.active != "" && !s.keyboard && s.Carry.carrying == "" { + s.end() + } } s.order, s.laying = s.laying, s.order[:0] for id := range s.targets { - if !slices.Contains(s.order, id) { + // The item in the air keeps its target while it is not laid out, + // for SortCarry.Hold to keep the drag's source alive. + if !slices.Contains(s.order, id) && id != s.active { delete(s.targets, id) } } @@ -324,7 +329,7 @@ func (s *Sortable) pointer(gtx C, t *sortTarget, e pointer.Event) { if s.active == "" && s.delta.X*s.delta.X+s.delta.Y*s.delta.Y >= travel*travel { s.active, s.keyboard = t.id, false if s.Carry != nil { - s.Carry.carrying = t.id + s.Carry.carrying, s.Carry.source = t.id, s } } case pointer.Release: @@ -385,7 +390,7 @@ func (s *Sortable) finish(over string, carried bool) { func (s *Sortable) end() { if s.Carry != nil && s.active != "" && s.Carry.carrying == s.active { - s.Carry.carrying = "" + s.Carry.carrying, s.Carry.source = "", nil } s.active, s.keyboard, s.kbOver, s.pressed = "", false, "", "" } diff --git a/native/ui/sortable_list.go b/native/ui/sortable_list.go index e302214a..696d43cc 100644 --- a/native/ui/sortable_list.go +++ b/native/ui/sortable_list.go @@ -38,6 +38,12 @@ type SortList struct { // withinList. Without it the item goes wherever the pointer takes it // along the axis, out to a SortCarry target. Within bool + // Leaves lets an item the pointer carries out across the list's side + // leave it: the item goes back to its slot and the others stop making + // room, the caller drawing what follows the pointer out there. Let go + // there, it lands on a SortCarry target or nowhere. Without Leaves the + // item stays on the list's axis. + Leaves bool } // sortList is the geometry List laid out last frame, which Over reads before @@ -52,6 +58,9 @@ type sortList struct { horizontal bool press f32.Point slots map[string]*sortSlot + // away is the item in the air carried out across the list's side this + // frame, which then makes room nowhere. + away bool } // sortSlot is an item's offset from its slot, animated, and where it was @@ -74,10 +83,18 @@ func (s *Sortable) List(gtx C, l SortList, ids []string, item func(gtx C, i int) calls, cross := s.measure(gtx, l, ids, item) a := slices.Index(ids, s.active) size := s.list.size(cross) + s.list.away = l.Leaves && a >= 0 && !s.keyboard && s.list.pastSide(s, size) defer clip.Rect{Max: size}.Push(gtx.Ops).Pop() if !s.Disabled { event.Op(gtx.Ops, &s.list) } + if s.list.away { + for j := range ids { + s.place(gtx, j, s.offset(gtx, j, -1), calls[j]) + } + s.prune(ids) + return D{Size: size} + } for j := range ids { if j != a { s.place(gtx, j, s.offset(gtx, j, a), calls[j]) @@ -90,6 +107,16 @@ func (s *Sortable) List(gtx C, l SortList, ids []string, item func(gtx C, i int) return D{Size: size} } +// pastSide reports whether the pointer carrying the item in the air is +// beyond the list's sides, across its axis. +func (g *sortList) pastSide(s *Sortable, size image.Point) bool { + at := s.press.Add(s.delta) + if g.horizontal { + return at.Y < 0 || at.Y > float32(size.Y) + } + return at.X < 0 || at.X > float32(size.X) +} + // listPress keeps where the last press landed in the list, which the hang // below a SortCarry target is measured from. func (s *Sortable) listPress(gtx C) { @@ -270,7 +297,7 @@ func (g *sortList) over(s *Sortable, a int) int { } return a } - if s.Carry != nil && s.Carry.Over() != "" { + if g.away || s.Carry != nil && s.Carry.Over() != "" { return a } center := g.tops[a] + g.heights[a]/2 + g.dy(s, a) @@ -315,7 +342,9 @@ func (g *sortList) target(s *Sortable, j, a int) int { type SortCarry struct { targets map[string]*carryTarget carrying string - latest time.Time + // source is the Sortable the item in the air was picked up from. + source *Sortable + latest time.Time } // carryTarget is a target's event tag; laid is the frame it was last laid @@ -329,6 +358,24 @@ type carryTarget struct { // is, for a target to decide whether it takes it. func (c *SortCarry) Carrying() string { return c.carrying } +// Release drops the item in the air as if let go over nothing, for a drag +// whose own Sortable is no longer laid out to hear the pointer go up; that +// Sortable lets the item go when it is next updated. +func (c *SortCarry) Release() { c.carrying, c.source = "", nil } + +// Hold keeps the drag's source alive for a frame in which the list it was +// picked up from may not be laid out, as when the screen under the drag +// changed: Gio forgets a handler nobody asked about in a frame, and a drag +// whose source it forgot brings the window down. Call it every frame. +func (c *SortCarry) Hold(gtx C) { + if c.carrying == "" || c.source == nil { + return + } + if t := c.source.targets[c.carrying]; t != nil { + c.source.hover(gtx, t) + } +} + // Over is the target under the pointer while an item is carried, "" for // none: the header that would draw as the drop target. func (c *SortCarry) Over() string { diff --git a/native/ui/sortable_list_test.go b/native/ui/sortable_list_test.go index c79e86bb..68be4f8b 100644 --- a/native/ui/sortable_list_test.go +++ b/native/ui/sortable_list_test.go @@ -36,6 +36,10 @@ type listRig struct { probes map[string]*bool drops []SortDrop hovered []string + // leaves is the inner lists' SortList.Leaves; hideA leaves block A's + // cards out of the frame, as a sidebar switched to another project does. + leaves bool + hideA bool } const listRow = 20 @@ -61,6 +65,7 @@ func (r *listRig) frame() { var ops op.Ops gtx := layout.Context{Ops: &ops, Now: r.now, Source: r.router.Source(), Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, Constraints: layout.Constraints{Max: image.Pt(300, 300)}} r.readProbes(gtx) + r.carry.Hold(gtx) r.collect(r.outer.Update(gtx)) r.outer.List(gtx, SortList{Within: true}, r.blocks, func(gtx C, i int) D { return r.block(gtx, r.blocks[i]) }) r.router.Frame(&ops) @@ -87,7 +92,10 @@ func (r *listRig) block(gtx C, id string) D { return header(gtx) }), layout.Rigid(func(gtx C) D { - return in.List(gtx, SortList{}, r.cards[id], func(gtx C, i int) D { return r.card(gtx, in, r.cards[id][i]) }) + if id == "A" && r.hideA { + return D{} + } + return in.List(gtx, SortList{Leaves: r.leaves}, r.cards[id], func(gtx C, i int) D { return r.card(gtx, in, r.cards[id][i]) }) }), ) } @@ -286,3 +294,67 @@ func TestSortListSettlesIntoTheNewSlot(t *testing.T) { t.Errorf("settled: under (5, 45) %v, want a1 in slot 2", under) } } + +// Carried out past the 40px list's side, a1 is back in its slot, a2 makes +// no room, and let go there it lands nowhere. +func TestSortListLeavesTheListPastItsSide(t *testing.T) { + r := newListRig() + r.leaves = true + r.press(onA1) + r.move(a1Lifted, image.Pt(150, 52)) + if under := r.probe(image.Pt(5, 25)); !slices.Contains(under, "a1") { + t.Errorf("under a1's slot: %v, want a1 back in it", under) + } + if under := r.probe(image.Pt(5, 45)); !slices.Equal(under, []string{"a2"}) { + t.Errorf("under a2's slot: %v, want a2 unmoved", under) + } + r.release(image.Pt(150, 52)) + r.want(t, SortDrop{ID: "a1", Over: "a1"}) +} + +// Brought back over the list, the item rides its axis and makes room again. +func TestSortListLeavesComesBackIntoTheList(t *testing.T) { + r := newListRig() + r.leaves = true + r.press(onA1) + r.move(a1Lifted, image.Pt(150, 52), image.Pt(20, 52)) + if got := r.inner["A"].Over(); got != "a2" { + t.Errorf("over %q back in the list, want a2", got) + } +} + +// A carry released for a drag whose list stopped being laid out lets that +// list's Sortable go of the item, so the next press picks up again. +func TestSortCarryReleaseEndsAStrandedDrag(t *testing.T) { + r := newListRig() + r.press(onA1) + r.move(a1Lifted, image.Pt(20, 52)) + if r.carry.Carrying() != "a1" { + t.Fatalf("carrying %q, want a1", r.carry.Carrying()) + } + r.carry.Release() + r.wait(time.Millisecond) + if got := r.inner["A"].Dragging(); got != "" { + t.Fatalf("still dragging %q after the carry was released", got) + } + r.release(image.Pt(20, 52)) + r.want(t) +} + +// The list a1 was picked up from stops being laid out mid-drag: the drag's +// source is held, the pointer keeps moving and letting go over nothing ends +// it, where Gio would otherwise trip over the source it forgot. +func TestSortCarryHoldsTheSourceOfADragWhoseListLeft(t *testing.T) { + r := newListRig() + r.press(onA1) + r.move(a1Lifted, image.Pt(20, 52)) + r.hideA = true + r.move(image.Pt(60, 90), image.Pt(200, 200)) + r.release(image.Pt(200, 200)) + r.carry.Release() + r.hideA = false + r.wait(time.Millisecond) + if got := r.inner["A"].Dragging(); got != "" { + t.Fatalf("still dragging %q once A is back", got) + } +} From 9d585e9199ee5dd805ad312414a507f80f9dfaf8 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 17 Oct 2026 22:37:11 -0300 Subject: [PATCH 2/2] feat(native): show a session's delegates beside it A session that opened others, in any project, offers them in the footer while it is in no layout: Show beside opens a layout with it at the left and its delegates stacked at the right, as many as fit the pane floor. In a layout its pane header counts the delegates there and adds the rest under the last one, asking first before taking one out of another layout. A card in a layout names it. --- CHANGELOG.md | 5 + docs/ceilings.md | 5 +- native/cmd/lich-native/delegates_window.go | 66 ++++++++++ native/cmd/lich-native/footer.go | 30 ++++- native/cmd/lich-native/footer_slots.go | 3 + native/cmd/lich-native/layouts.go | 41 ++++++ native/cmd/lich-native/layouts_drag.go | 17 +++ native/cmd/lich-native/layouts_drag_test.go | 22 ++++ native/cmd/lich-native/layouts_test.go | 29 +++++ native/cmd/lich-native/layouts_window.go | 35 +++++- native/cmd/lich-native/main.go | 1 + native/cmd/lich-native/model.go | 3 + native/cmd/lich-native/model_delegates.go | 119 ++++++++++++++++++ .../cmd/lich-native/model_delegates_test.go | 106 ++++++++++++++++ native/cmd/lich-native/model_layouts.go | 9 ++ native/cmd/lich-native/model_layouts_test.go | 18 +++ native/cmd/lich-native/sidebar_card.go | 34 ++++- native/cmd/lich-native/stage_layout.go | 14 ++- native/i18n/locales/en/layouts.json | 16 +++ native/i18n/locales/es/layouts.json | 16 +++ native/i18n/locales/ja/layouts.json | 13 ++ native/i18n/locales/ko/layouts.json | 13 ++ native/i18n/locales/pt-br/layouts.json | 16 +++ native/i18n/locales/ru/layouts.json | 22 ++++ native/i18n/locales/zh-cn/layouts.json | 13 ++ 25 files changed, 651 insertions(+), 15 deletions(-) create mode 100644 native/cmd/lich-native/delegates_window.go create mode 100644 native/cmd/lich-native/model_delegates.go create mode 100644 native/cmd/lich-native/model_delegates_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e0eabc73..c39e74a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 layout down to one pane closes. Rest a dragged session on another tab and that tab comes up, as a browser does with a dragged file. The sidebar and the footer follow the pane in focus. +- **Show a session's delegates beside it.** In the native window, a session + that opened others, in any project, offers them in the footer: Show beside + lays it out with its delegates stacked at its right, as many as fit. In a + layout its pane header counts how many are there and adds the rest, asking + first before taking one out of another layout. A card in a layout names it. - **Required checks are labelled.** In the native window, a pull request's Checks tab marks the checks that gate its merge with Required, as GitHub does. diff --git a/docs/ceilings.md b/docs/ceilings.md index 69b8e10e..4b13e009 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -277,7 +277,10 @@ work when nobody knows it and that the call site never shows. The mechanism and heard the release go (`endStrandedDrag`). The layout hotkeys the web window has no action for (split below, zoom, take out of the layout, rotate) live in the shared `hotkeys.bindings` value, which the web window rewrites with its own actions only: rebinding anything there drops a custom chord set - for those four in the native window. + for those four in the native window. Show beside lays out only the delegates that fit the 180dp pane + floor, counted from where the panes were drawn the frame before (`columnRoom`); the rest stay out and + the pane header offers them. Its footer item is not an item of the Footer layout, so Settings can neither + move nor hide it: it shows only while the session, in no layout, has delegates. - **A theme switch reaches only the apps that asked, and only since the page loaded** (`frontend/src/lib/terminal/theme-notify.ts`): lich answers mode 2031 and sends the light or dark report on a switch, but the "asked" flag lives on the page's terminal entry. A full page reload forgets it, so a session diff --git a/native/cmd/lich-native/delegates_window.go b/native/cmd/lich-native/delegates_window.go new file mode 100644 index 00000000..d3f2903a --- /dev/null +++ b/native/cmd/lich-native/delegates_window.go @@ -0,0 +1,66 @@ +package main + +import ( + "gioui.org/layout" + + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" + "github.com/lichdotdev/lich/native/ui/icons" +) + +var icDelegates = icons.Lucide("network") + +// delegatesChip is a pane header's offer to lay out the delegates its +// session opened that are not in the layout yet, nothing while the layout +// is zoomed or every delegate is there. +func (u *window) delegatesChip(gtx C, th *ui.Theme, v stageView, sv sessionView, p *paneUI) D { + if v.layout.Zoomed { + return D{} + } + o := u.model.delegateOffer(sv.ID) + if len(o.missing) == 0 { + return D{} + } + tip := th.Tooltip(&u.root, &p.delegatesTip, i18n.T("layouts.delegates.hint")) + return layout.Inset{Right: ui.Space(2)}.Layout(gtx, func(gtx C) D { + return tip.Layout(gtx, func(gtx C) D { + return p.delegates.Layout(gtx, func(gtx C) D { + return pill(gtx, th, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return icDelegates.Layout(gtx, 12, th.MutedForeground) }), + ui.Gap(1.5), + layout.Rigid(th.Text(ui.TextXS, delegatesText(o)).Muted().Layout), + ) + }, ui.Space(2), ui.Space(2)) + }) + }) + }) +} + +// delegatesText is the chip's words: how many delegates there are, and how +// many of them the layout shows once it shows any. +func delegatesText(o delegateOffer) string { + if o.here == 0 { + return i18n.T("layouts.delegates.showBeside", i18n.Args{"count": len(o.all)}) + } + return i18n.T("layouts.delegates.add", i18n.Args{"here": o.here, "count": len(o.all), "missing": len(o.missing)}) +} + +// layDelegatesOut lays out as many of orchestrator's missing delegates as +// fit at delegateSlot, refused with a toast when none does and asked about +// first when any sits in another layout. +func (u *window) layDelegatesOut(orchestrator string) { + o := u.model.delegateOffer(orchestrator) + target, below := u.model.delegateSlot(orchestrator, o.all) + ids := o.missing[:min(len(o.missing), u.layoutStage.pointer.columnRoom(target, below))] + if len(ids) == 0 { + u.toaster.Show(ui.Toast{Title: i18n.T("layouts.split.noRoom")}) + return + } + if away := u.model.inOtherLayouts(orchestrator, ids); away > 0 { + u.askMoveDelegates(orchestrator, ids, away) + return + } + u.model.showDelegates(u.ctx, orchestrator, ids) + u.model.invalidate() +} diff --git a/native/cmd/lich-native/footer.go b/native/cmd/lich-native/footer.go index a44147ca..56a442ac 100644 --- a/native/cmd/lich-native/footer.go +++ b/native/cmd/lich-native/footer.go @@ -34,10 +34,12 @@ type footer struct { invalidate func() // openPullRequest opens the Pulls screen on a pull request. openPullRequest func(number int) - usage *footerUsage - quota *planQuota - handsOn *handsOn - cost *costGate + // showDelegates lays a session's delegates out beside it. + showDelegates func(id string) + usage *footerUsage + quota *planQuota + handsOn *handsOn + cost *costGate mu sync.Mutex layout footerLayout @@ -116,6 +118,9 @@ func (f *footer) input(m *model, now time.Time) footerInput { if in.provider == "" { in.provider = sv.Kind } + if m.layoutOf(sv.ID) == "" { + in.delegates = len(m.delegatesOf(sv.ID)) + } } else if p, ok := m.project(); ok { in.path = p.Path } @@ -173,13 +178,23 @@ func (f *footer) layoutBar(gtx C, th *ui.Theme, in footerInput) D { // min-h-11 counts the padding, and Inset leaves Min as it finds it. gtx.Constraints.Min.Y = gtx.Dp(ui.Space(11)) - 2*gtx.Dp(pad) return ui.Wrap{GapX: ui.Space(4), GapY: ui.Space(1), Justify: ui.JustifyBetween}.Layout(gtx, - ui.WrapItem(f.side(th, footerSlots(l.Left, in), in, ui.JustifyStart)), + ui.WrapItem(f.side(th, withDelegates(footerSlots(l.Left, in), in), in, ui.JustifyStart)), ui.WrapMLAuto(f.side(th, footerSlots(l.Right, in), in, ui.JustifyEnd)), ) }) }) } +// withDelegates ends the left side with the delegates item while the +// active session has delegates to lay out. It is no item of the user's +// layout: it shows only when there is something to offer. +func withDelegates(left []footerSlot, in footerInput) []footerSlot { + if in.delegates == 0 { + return left + } + return append(left, footerSlot{ID: "delegates", Text: i18n.T("layouts.delegates.showBeside", i18n.Args{"count": in.delegates})}) +} + func (f *footer) side(th *ui.Theme, slots []footerSlot, in footerInput, justify ui.Justify) layout.Widget { return func(gtx C) D { kids := make([]ui.WrapChild, len(slots)) @@ -206,6 +221,9 @@ func (f *footer) act(gtx C, in footerInput) { if f.w("pr").click.Clicked(gtx) && in.pr != nil && f.openPullRequest != nil { f.openPullRequest(in.pr.Number) } + if f.w("delegates").click.Clicked(gtx) && in.delegates > 0 { + f.showDelegates(in.sessionID) + } } // attach picks a file for the session and pastes its path at the prompt. @@ -248,6 +266,8 @@ func (f *footer) slot(gtx C, th *ui.Theme, s footerSlot, in footerInput) D { case "model": glyph := func(gtx C) D { return providerIcon(in.provider).Layout(gtx, 14, th.MutedForeground) } return footerSpan(gtx, th, f.root, &w.tip, s.Hint, modelMaxWidth, glyph, s.Text) + case "delegates": + return footerButton(gtx, th, f.root, w, i18n.T("layouts.delegates.hint"), true, false, glyphText(icDelegates, s.Text)) case "clock": return layout.Inset{Left: ui.Space(2), Right: ui.Space(2)}.Layout(gtx, th.Text(ui.TextXS, s.Text).Muted().Layout) } diff --git a/native/cmd/lich-native/footer_slots.go b/native/cmd/lich-native/footer_slots.go index fa8e847e..a46db457 100644 --- a/native/cmd/lich-native/footer_slots.go +++ b/native/cmd/lich-native/footer_slots.go @@ -27,6 +27,9 @@ type footerInput struct { cost bool // the cost readout is turned on budget float64 // the spend ceiling, 0 for none now time.Time + // delegates counts the sessions the active one opened, while it is in no + // layout: in one, its pane header offers them instead. + delegates int } // footerSlot is one item the strip draws, with what it shows; an item whose diff --git a/native/cmd/lich-native/layouts.go b/native/cmd/lich-native/layouts.go index 9f128033..162086f3 100644 --- a/native/cmd/lich-native/layouts.go +++ b/native/cmd/lich-native/layouts.go @@ -171,6 +171,36 @@ func (t *layoutTab) rotate(session string) bool { return true } +// stackUnder puts ids under target's pane, the column of them all sharing +// target's area evenly. It reports false when target is not in the tab. +func (t *layoutTab) stackUnder(target string, ids []string) bool { + n := findLeaf(t.Root, target) + if n == nil { + return false + } + *n = *stackColumn(append([]string{target}, ids...)) + return true +} + +// columnBeside halves target's pane, a column of ids taking its right half. +// It reports false when target is not in the tab. +func (t *layoutTab) columnBeside(target string, ids []string) bool { + n := findLeaf(t.Root, target) + if n == nil { + return false + } + *n = paneNode{Axis: splitRow, Ratio: 0.5, First: &paneNode{Session: target}, Second: stackColumn(ids)} + return true +} + +// stackColumn is ids stacked top to bottom, each taking an even share. +func stackColumn(ids []string) *paneNode { + if len(ids) == 1 { + return &paneNode{Session: ids[0]} + } + return &paneNode{Axis: splitColumn, Ratio: 1 / float64(len(ids)), First: &paneNode{Session: ids[0]}, Second: stackColumn(ids[1:])} +} + // parentOf is the split whose side is session's leaf, nil for none. func parentOf(n *paneNode, session string) *paneNode { if n == nil || n.leaf() { @@ -249,6 +279,17 @@ func (ls *layoutTabs) open(name, first, second string, edge paneEdge) string { return t.ID } +// openBeside makes a layout of left with a column of ids at its right, +// named name and focused on left, and answers its id. As with open, none of +// them may be in a layout already. +func (ls *layoutTabs) openBeside(name, left string, column []string) string { + ls.seq++ + t := layoutTab{ID: fmt.Sprintf("layout-%d", ls.seq), Name: name, Root: &paneNode{Session: left}, Focus: left} + t.columnBeside(left, column) + ls.tabs = append(ls.tabs, t) + return t.ID +} + // take removes session from whichever layout holds it, reporting whether // one did. A layout left with a single pane is no layout any more: it goes, // and left is the session that pane showed, "" while the layout stays. diff --git a/native/cmd/lich-native/layouts_drag.go b/native/cmd/lich-native/layouts_drag.go index 2d6031aa..d8cccb24 100644 --- a/native/cmd/lich-native/layouts_drag.go +++ b/native/cmd/lich-native/layouts_drag.go @@ -218,6 +218,23 @@ func (d *dragPointer) fits(target string, edge paneEdge) bool { return true } +// columnRoom is how many panes a column fits in target's pane as last +// drawn: under target, below set, else in a column taking its right half. +func (d *dragPointer) columnRoom(target string, below bool) int { + r, ok := d.last[target] + if !ok { + return 0 + } + stacked := (r.Dy() + d.gap) / (d.paneH + d.gap) + if below { + return stacked - 1 + } + if r.Dx() < 2*d.paneW+d.gap { + return 0 + } + return stacked +} + // zoneAt is the drop zone at p of a pane other than carried, "" for none. // Only a pane of the layout on screen swaps with another, through the // middle; a card from the sidebar or a session alone has none. diff --git a/native/cmd/lich-native/layouts_drag_test.go b/native/cmd/lich-native/layouts_drag_test.go index 01584059..d2b7e0e8 100644 --- a/native/cmd/lich-native/layouts_drag_test.go +++ b/native/cmd/lich-native/layouts_drag_test.go @@ -102,3 +102,25 @@ func TestPickZoneGivesALeftOutEdgeToTheOthers(t *testing.T) { } } } + +func TestColumnRoomCountsThePanesThatFit(t *testing.T) { + d := dragPointer{ + last: map[string]image.Rectangle{"wide": image.Rect(0, 0, 1000, 600), "narrow": image.Rect(0, 0, 900, 600)}, + paneW: 480, paneH: 180, gap: 6, + } + cases := []struct { + target string + below bool + want int + }{ + {"wide", false, 3}, + {"narrow", false, 0}, + {"narrow", true, 2}, + {"missing", false, 0}, + } + for _, c := range cases { + if got := d.columnRoom(c.target, c.below); got != c.want { + t.Errorf("columnRoom(%s, below %v) = %d, want %d", c.target, c.below, got, c.want) + } + } +} diff --git a/native/cmd/lich-native/layouts_test.go b/native/cmd/lich-native/layouts_test.go index bd8c6493..0685bd2c 100644 --- a/native/cmd/lich-native/layouts_test.go +++ b/native/cmd/lich-native/layouts_test.go @@ -223,3 +223,32 @@ func TestPaneHeaderNamesOnlyAWorktree(t *testing.T) { t.Errorf("worktree label = %q, want imfl", got) } } + +func TestOpenBesideStacksTheDelegatesEvenlyAtTheRight(t *testing.T) { + var ls layoutTabs + tab := ls.byID(ls.openBeside("orch", "orch", []string{"w1", "w2", "w3"})) + if got, want := paneShape(tab.Root), "row(orch,column(w1,column(w2,w3)))"; got != want { + t.Fatalf("got %s, want %s", got, want) + } + column := tab.Root.Second + if column.Ratio != 1.0/3 || column.Second.Ratio != 0.5 { + t.Errorf("column ratios %v and %v, want each pane a third", column.Ratio, column.Second.Ratio) + } + if tab.Focus != "orch" { + t.Errorf("focus %q, want the orchestrator", tab.Focus) + } +} + +func TestStackUnderSharesThePaneItJoins(t *testing.T) { + var ls layoutTabs + tab := ls.byID(ls.openBeside("orch", "orch", []string{"w1"})) + if !tab.stackUnder("w1", []string{"w2", "w3"}) { + t.Fatal("stackUnder refused a pane the tab holds") + } + if got, want := paneShape(tab.Root), "row(orch,column(w1,column(w2,w3)))"; got != want { + t.Fatalf("got %s, want %s", got, want) + } + if tab.stackUnder("missing", []string{"w4"}) { + t.Fatal("stackUnder into a session the tab does not hold succeeded") + } +} diff --git a/native/cmd/lich-native/layouts_window.go b/native/cmd/lich-native/layouts_window.go index c17971c5..d5f7aa92 100644 --- a/native/cmd/lich-native/layouts_window.go +++ b/native/cmd/lich-native/layouts_window.go @@ -76,14 +76,24 @@ type moveConfirm struct { session string label string from string - cancel widget.Clickable - confirm widget.Clickable + // orchestrator and delegates are a delegates chip's move instead: the + // delegates to lay out, away how many of them are in other layouts. + orchestrator string + delegates []string + away int + cancel widget.Clickable + confirm widget.Clickable } func (u *window) askMove(r besideRequest, id, from string) { c := &u.layoutStage.move c.open, c.req, c.session, c.from = true, r, id, u.model.layoutName(from) - c.label = u.model.sessionLabel(id) + c.label, c.delegates = u.model.sessionLabel(id), nil +} + +func (u *window) askMoveDelegates(orchestrator string, ids []string, away int) { + c := &u.layoutStage.move + c.open, c.orchestrator, c.delegates, c.away = true, orchestrator, ids, away } // layoutMoveConfirm draws the question and makes the move it is answered @@ -92,7 +102,11 @@ func (u *window) layoutMoveConfirm(gtx C, th *ui.Theme) { c := &u.layoutStage.move if c.confirm.Clicked(gtx) && c.open { c.open = false - u.model.splitBeside(u.ctx, c.req.target, c.session, c.req.edge, true) + if c.delegates != nil { + u.model.showDelegates(u.ctx, c.orchestrator, c.delegates) + } else { + u.model.splitBeside(u.ctx, c.req.target, c.session, c.req.edge, true) + } u.model.invalidate() } if c.cancel.Clicked(gtx) { @@ -102,10 +116,19 @@ func (u *window) layoutMoveConfirm(gtx C, th *ui.Theme) { cancel := on.Button(&c.cancel, i18n.T("common.action.cancel")) cancel.Variant = ui.VariantOutline move := on.Button(&c.confirm, i18n.T("layouts.move.confirm")) - body := i18n.T("layouts.move.body", i18n.Args{"label": c.label, "layout": c.from}) - d := th.Dialog(&c.dialog, c.open, i18n.T("layouts.move.title", i18n.Args{"label": c.label}), body) + title, body := c.question() + d := th.Dialog(&c.dialog, c.open, title, body) d.Actions = []layout.Widget{cancel.Layout, move.Layout} if d.Layout(gtx) { c.open = false } } + +func (c *moveConfirm) question() (title, body string) { + if c.delegates != nil { + return i18n.T("layouts.delegates.moveTitle", i18n.Args{"count": len(c.delegates)}), + i18n.T("layouts.delegates.moveBody", i18n.Args{"count": c.away}) + } + return i18n.T("layouts.move.title", i18n.Args{"label": c.label}), + i18n.T("layouts.move.body", i18n.Args{"label": c.label, "layout": c.from}) +} diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 5476237d..ecb68efd 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -264,6 +264,7 @@ func run(w *app.Window, rt lichclient.Runtime) error { u.dock.tab = map[string]dockTab{"code": dockCode, "review": dockReview}[*flagTab] u.footer.dock = u.dock u.footer.openPullRequest = u.showPulls + u.footer.showDelegates = func(id string) { u.layoutStage.delegates = id } u.settings.footer = u.footer u.footer.customize = u.customizeFooter if err := u.connect(); err != nil { diff --git a/native/cmd/lich-native/model.go b/native/cmd/lich-native/model.go index 37683ae9..512bf850 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -522,6 +522,8 @@ type sessionView struct { // continueIn are the other logins its conversation could move to. profileName string continueIn []continueTarget + // layout is the name of the layout the session sits in, "" for none. + layout string } // viewOf is what s's card shows at now. Callers hold m.mu. @@ -536,6 +538,7 @@ func (m *model) viewOf(p lichclient.Project, s lichclient.Session, now time.Time base: m.base[shown], pr: m.prs[shown].pr, relay: m.relay[s.ID], tool: tool, reason: m.status.reason(s.ID), profileName: m.profileNameOf(s.Kind, s.Profile), continueIn: m.continueTargets(s.Kind, s.Profile), + layout: m.layoutNameOfLocked(s.ID), } } diff --git a/native/cmd/lich-native/model_delegates.go b/native/cmd/lich-native/model_delegates.go new file mode 100644 index 00000000..1dd835c0 --- /dev/null +++ b/native/cmd/lich-native/model_delegates.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "slices" +) + +// The delegates of a session are the sessions it opened, in any project: +// the backend keeps the opener on each one (OriginSessionID). + +// delegatesOf is every live session orchestrator opened, in project order +// and each project's own session order. +func (m *model) delegatesOf(orchestrator string) []string { + m.mu.Lock() + defer m.mu.Unlock() + return m.delegatesLocked(orchestrator) +} + +func (m *model) delegatesLocked(orchestrator string) []string { + var ids []string + for _, p := range m.projects { + for _, s := range p.Sessions { + if s.OriginSessionID == orchestrator && s.ID != orchestrator { + ids = append(ids, s.ID) + } + } + } + return ids +} + +// delegateSlot is where orchestrator's next delegates go in its layout: +// under the last of its delegates there, else in a column beside +// orchestrator itself. below tells the two apart. +func (m *model) delegateSlot(orchestrator string, delegates []string) (target string, below bool) { + m.mu.Lock() + defer m.mu.Unlock() + return m.delegateSlotLocked(orchestrator, delegates) +} + +func (m *model) delegateSlotLocked(orchestrator string, delegates []string) (target string, below bool) { + i := m.layouts.of(orchestrator) + if i < 0 { + return orchestrator, false + } + here := slices.DeleteFunc(m.layouts.tabs[i].sessions(), func(id string) bool { return !slices.Contains(delegates, id) }) + if len(here) == 0 { + return orchestrator, false + } + return here[len(here)-1], true +} + +// showDelegates lays orchestrator out with ids in a column at its right: +// in a new layout named after it when it is in none, else in its own layout +// at delegateSlot. A delegate in another layout is taken out of it; asking +// first is the caller's. It reports false when nothing was laid out. +func (m *model) showDelegates(ctx context.Context, orchestrator string, ids []string) bool { + m.mu.Lock() + if len(ids) == 0 || !m.liveLocked(orchestrator) || slices.ContainsFunc(ids, func(id string) bool { return !m.liveLocked(id) }) { + m.mu.Unlock() + return false + } + target, below := m.delegateSlotLocked(orchestrator, m.delegatesLocked(orchestrator)) + for _, id := range ids { + m.layouts.take(id) + } + switch i := m.layouts.of(orchestrator); { + case i < 0: + p, at := m.sessionAt(orchestrator) + m.layouts.openBeside(p.Sessions[at].Label, orchestrator, ids) + case below: + m.layouts.tabs[i].stackUnder(target, ids) + default: + m.layouts.tabs[i].columnBeside(target, ids) + } + m.mu.Unlock() + m.setActive(ctx, orchestrator) + m.saveLayouts() + return true +} + +// delegateOffer is what orchestrator's delegates chip offers: how many +// delegates it has, how many of them sit in its layout, and the rest. +type delegateOffer struct { + all []string + here int + missing []string +} + +func (m *model) delegateOffer(orchestrator string) delegateOffer { + m.mu.Lock() + defer m.mu.Unlock() + o := delegateOffer{all: m.delegatesLocked(orchestrator)} + var layout *layoutTab + if i := m.layouts.of(orchestrator); i >= 0 { + layout = &m.layouts.tabs[i] + } + for _, id := range o.all { + if layout != nil && layout.holds(id) { + o.here++ + continue + } + o.missing = append(o.missing, id) + } + return o +} + +// inOtherLayouts counts the ids that sit in a layout other than +// orchestrator's, which laying them out beside it would take them out of. +func (m *model) inOtherLayouts(orchestrator string, ids []string) int { + m.mu.Lock() + defer m.mu.Unlock() + own, n := m.layouts.of(orchestrator), 0 + for _, id := range ids { + if at := m.layouts.of(id); at >= 0 && at != own { + n++ + } + } + return n +} diff --git a/native/cmd/lich-native/model_delegates_test.go b/native/cmd/lich-native/model_delegates_test.go new file mode 100644 index 00000000..4cdd1487 --- /dev/null +++ b/native/cmd/lich-native/model_delegates_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "reflect" + "testing" +) + +// newDelegatesModel is newLayoutsModel with worker-a and worker-b, in +// another project, opened by the orchestrator. +func newDelegatesModel(t *testing.T) (*model, *layoutsBackend) { + t.Helper() + m, b := newLayoutsModel(t) + for i := range m.projects[1].Sessions { + m.projects[1].Sessions[i].OriginSessionID = "orch" + } + return m, b +} + +func shownShape(t *testing.T, m *model) string { + t.Helper() + lt, ok := m.shownLayoutTab() + if !ok { + return "" + } + return paneShape(lt.Root) +} + +func TestDelegatesReachIntoOtherProjects(t *testing.T) { + m, _ := newDelegatesModel(t) + if got := m.delegatesOf("orch"); !reflect.DeepEqual(got, []string{"wa", "wb"}) { + t.Fatalf("delegates %v, want both workers of lich-plugin", got) + } + if got := m.delegatesOf("notes"); got != nil { + t.Fatalf("a session that opened nothing has delegates %v", got) + } +} + +func TestShowDelegatesOpensALayoutNamedAfterTheOrchestrator(t *testing.T) { + m, b := newDelegatesModel(t) + if !m.showDelegates(context.Background(), "orch", []string{"wa", "wb"}) { + t.Fatal("show refused") + } + if got := shownShape(t, m); got != "row(orch,column(wa,wb))" { + t.Fatalf("on screen %s", got) + } + if _, project, active := layoutScreen(t, m); project != "lich" || active != "orch" { + t.Fatalf("focus on %q in %q, want the orchestrator", active, project) + } + saved, err := parseLayouts(b.last(t, 1)) + if err != nil || len(saved.tabs) != 1 || saved.tabs[0].Name != "orchestrator" { + t.Fatalf("stored %+v, %v", saved.tabs, err) + } +} + +func TestShowDelegatesStacksUnderTheOnesAlreadyThere(t *testing.T) { + m, _ := newDelegatesModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + if target, below := m.delegateSlot("orch", m.delegatesOf("orch")); target != "wa" || !below { + t.Fatalf("slot %q below %v, want under worker-a", target, below) + } + m.showDelegates(ctx, "orch", []string{"wb"}) + if got := shownShape(t, m); got != "row(orch,column(wa,wb))" { + t.Fatalf("on screen %s", got) + } +} + +func TestShowDelegatesBesideTheOrchestratorWhenNoneIsThere(t *testing.T) { + m, _ := newDelegatesModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "notes", edgeRight, false) + m.showDelegates(ctx, "orch", []string{"wa"}) + if got := shownShape(t, m); got != "row(row(orch,wa),notes)" { + t.Fatalf("on screen %s", got) + } +} + +func TestShowDelegatesTakesADelegateOutOfAnotherLayout(t *testing.T) { + m, _ := newDelegatesModel(t) + ctx := context.Background() + m.splitBeside(ctx, "notes", "wb", edgeRight, false) + m.showDelegates(ctx, "orch", []string{"wa", "wb"}) + if got := m.layoutOf("notes"); got != "" { + t.Fatalf("notes still in layout %q after its only partner left", got) + } + if got := shownShape(t, m); got != "row(orch,column(wa,wb))" { + t.Fatalf("on screen %s", got) + } +} + +func TestDelegateOfferCountsTheOnesInTheOrchestratorsLayout(t *testing.T) { + m, _ := newDelegatesModel(t) + ctx := context.Background() + if o := m.delegateOffer("orch"); o.here != 0 || !reflect.DeepEqual(o.missing, []string{"wa", "wb"}) { + t.Fatalf("alone: %+v", o) + } + m.splitBeside(ctx, "notes", "wb", edgeRight, false) + if n := m.inOtherLayouts("orch", []string{"wa", "wb"}); n != 1 { + t.Fatalf("%d in other layouts, want worker-b's", n) + } + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + if o := m.delegateOffer("orch"); o.here != 1 || !reflect.DeepEqual(o.missing, []string{"wb"}) { + t.Fatalf("with worker-a beside: %+v", o) + } +} diff --git a/native/cmd/lich-native/model_layouts.go b/native/cmd/lich-native/model_layouts.go index 8b0cc57d..901aa96b 100644 --- a/native/cmd/lich-native/model_layouts.go +++ b/native/cmd/lich-native/model_layouts.go @@ -145,6 +145,15 @@ func cloneNode(n *paneNode) *paneNode { return &c } +// layoutNameOfLocked is the name of the layout holding session id, "" for +// none. Callers hold m.mu. +func (m *model) layoutNameOfLocked(id string) string { + if i := m.layouts.of(id); i >= 0 { + return m.layouts.tabs[i].Name + } + return "" +} + // layoutOf is the id of the layout holding session id, "" for none. func (m *model) layoutOf(id string) string { m.mu.Lock() diff --git a/native/cmd/lich-native/model_layouts_test.go b/native/cmd/lich-native/model_layouts_test.go index 323c6e63..328a7d79 100644 --- a/native/cmd/lich-native/model_layouts_test.go +++ b/native/cmd/lich-native/model_layouts_test.go @@ -324,3 +324,21 @@ func TestShowOtherThanNeverLeavesTheDraggedSessionAloneOnScreen(t *testing.T) { t.Fatalf("layout %q active %q, want orch, the session in no layout", id, active) } } + +func TestALayoutTabSaysAPaneWaitsWhileAnotherTabIsUp(t *testing.T) { + m, _ := newLayoutsModel(t) + ctx := context.Background() + m.splitBeside(ctx, "orch", "wa", edgeRight, false) + report(t, m, "wa", statusWaiting) + layoutTab := func() projectTab { + tabs, _ := m.stripTabs() + return tabs[len(tabs)-1] + } + if got := layoutTab().status; got != statusNone { + t.Fatalf("the layout on screen badged %q, want none: its panes say it", got) + } + m.setActive(ctx, "notes") + if got := layoutTab().status; got != statusWaiting { + t.Fatalf("the layout off screen badged %q, want waiting", got) + } +} diff --git a/native/cmd/lich-native/sidebar_card.go b/native/cmd/lich-native/sidebar_card.go index 1ed33c63..7394d917 100644 --- a/native/cmd/lich-native/sidebar_card.go +++ b/native/cmd/lich-native/sidebar_card.go @@ -160,7 +160,7 @@ func (s *sidebar) cardBody(gtx C, th *ui.Theme, sv *sessionView, prChip *widget. if rung, ok := statusRung(th, sv); ok { rows = append(rows, ui.VGap(2), layout.Rigid(rung)) } - rows = append(rows, ui.VGap(2), layout.Rigid(th.Text(ui.TextXS, path).Muted().Mono().LayoutTail)) + rows = append(rows, ui.VGap(2), layout.Rigid(func(gtx C) D { return pathRow(gtx, th, sv.layout, path) })) if sv.git.Branch != "" || sv.profileName != "" { rows = append(rows, ui.VGap(2), layout.Rigid(func(gtx C) D { return s.branchRow(gtx, th, sv, prChip) })) } @@ -169,6 +169,38 @@ func (s *sidebar) cardBody(gtx C, th *ui.Theme, sv *sessionView, prChip *widget. }) } +// pathRow is the card's path, after the chip of the layout the session sits +// in: clicking the card shows it there. +func pathRow(gtx C, th *ui.Theme, layoutName, path string) D { + text := th.Text(ui.TextXS, path).Muted().Mono().LayoutTail + if layoutName == "" { + return text(gtx) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return layoutChip(gtx, th, layoutName) }), + ui.Gap(1.5), + layout.Flexed(1, text), + ) +} + +// layoutChip is profileChip's outline around the layout glyph and name. +func layoutChip(gtx C, th *ui.Theme, name string) D { + macro := op.Record(gtx.Ops) + gtx.Constraints.Min = image.Point{} + gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(profileChipMaxW)) + d := layout.Inset{Left: ui.Space(1), Right: ui.Space(1.5), Top: unit.Dp(1), Bottom: unit.Dp(1)}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { return icTabLayout.Layout(gtx, 11, th.MutedForeground) }), + ui.Gap(1), + layout.Rigid(th.Text(profileChipSize, name).Muted().Layout), + ) + }) + call := macro.Stop() + insetBorder(gtx, d.Size, gtx.Dp(ui.RadiusSM), th.Over(th.Border)) + call.Add(gtx.Ops) + return d +} + // titleRow is the card's first line: the status ring around the provider // mark, a shield on a session that runs sandboxed, a fork on one born of a // fork, and the label, which stops diff --git a/native/cmd/lich-native/stage_layout.go b/native/cmd/lich-native/stage_layout.go index b6118baf..f4f41bdc 100644 --- a/native/cmd/lich-native/stage_layout.go +++ b/native/cmd/lich-native/stage_layout.go @@ -45,8 +45,8 @@ var ( // paneUI is one pane's widgets, kept across frames by session id. type paneUI struct { - zoom, unsplit, chip widget.Clickable - zoomTip, unsplitTip ui.Tooltip + zoom, unsplit, chip, delegates widget.Clickable + zoomTip, unsplitTip, delegatesTip ui.Tooltip } // paneDivider is the gap between a split's two sides, dragged to move the @@ -74,6 +74,8 @@ type layoutStage struct { // switchTab is a strip tab something carried rested on, to bring on // screen. switchTab string + // delegates is the session whose delegates chip was clicked. + delegates string // sort drags the panes by their headers, over the sidebar's carry. sort ui.Sortable } @@ -333,6 +335,9 @@ func (u *window) paneHeader(gtx C, th *ui.Theme, v stageView, sv sessionView, fo if p.unsplit.Clicked(gtx) { u.layoutStage.unsplit = sv.ID } + if p.delegates.Clicked(gtx) { + u.layoutStage.delegates = sv.ID + } fg, weight := th.MutedForeground, font.Normal if focused { fg, weight = th.Foreground, font.Medium @@ -354,6 +359,7 @@ func (u *window) paneHeader(gtx C, th *ui.Theme, v stageView, sv sessionView, fo layout.Flexed(1, th.Text(ui.TextXS, worktreeLabel(sv, v.projectPaths[sv.projectID])).Muted().Mono().Layout), ) }), + layout.Rigid(func(gtx C) D { return u.delegatesChip(gtx, th, v, sv, p) }), layout.Rigid(func(gtx C) D { return u.paneButtons(gtx, th, v, sv, p) }), ) }) @@ -434,6 +440,10 @@ func (u *window) applyLayoutStage() { if drop.ID != "" { u.dropOnPane(drop.ID, drop.Over) } + if id := st.delegates; id != "" { + st.delegates = "" + u.layDelegatesOut(id) + } if focus != "" { if active, ok := u.model.activeSession(); !ok || active.ID != focus { u.model.setActive(u.ctx, focus) diff --git a/native/i18n/locales/en/layouts.json b/native/i18n/locales/en/layouts.json index 807d72e5..b73dd7ed 100644 --- a/native/i18n/locales/en/layouts.json +++ b/native/i18n/locales/en/layouts.json @@ -32,5 +32,21 @@ "top": "Split above", "bottom": "Split below", "swap": "Swap" + }, + "delegates": { + "showBeside": { + "one": "{count} delegate · Show beside", + "other": "{count} delegates · Show beside" + }, + "add": "{here} of {count} here · Add {missing}", + "hint": "Lay the sessions this one opened out beside it", + "moveTitle": { + "one": "Lay {count} delegate out here?", + "other": "Lay {count} delegates out here?" + }, + "moveBody": { + "one": "{count} delegate is in another layout. Laying it out here takes it out of there; the session keeps running.", + "other": "{count} delegates are in other layouts. Laying them out here takes them out of there; the sessions keep running." + } } } diff --git a/native/i18n/locales/es/layouts.json b/native/i18n/locales/es/layouts.json index 8dbef5b7..869c1395 100644 --- a/native/i18n/locales/es/layouts.json +++ b/native/i18n/locales/es/layouts.json @@ -32,5 +32,21 @@ "top": "Dividir arriba", "bottom": "Dividir abajo", "swap": "Intercambiar" + }, + "delegates": { + "showBeside": { + "one": "{count} delegado · Mostrar al lado", + "other": "{count} delegados · Mostrar al lado" + }, + "add": "{here} de {count} aquí · Añadir {missing}", + "hint": "Coloca al lado las sesiones que abrió esta", + "moveTitle": { + "one": "¿Traer {count} delegado aquí?", + "other": "¿Traer {count} delegados aquí?" + }, + "moveBody": { + "one": "{count} delegado está en otro layout. Traerlo aquí lo saca de allí; la sesión sigue en marcha.", + "other": "{count} delegados están en otros layouts. Traerlos aquí los saca de allí; las sesiones siguen en marcha." + } } } diff --git a/native/i18n/locales/ja/layouts.json b/native/i18n/locales/ja/layouts.json index 4fd16bc9..f55e9f8c 100644 --- a/native/i18n/locales/ja/layouts.json +++ b/native/i18n/locales/ja/layouts.json @@ -30,5 +30,18 @@ "top": "上に分割", "bottom": "下に分割", "swap": "入れ替え" + }, + "delegates": { + "showBeside": { + "other": "委任先 {count} 件 · 横に表示" + }, + "add": "{count} 件中 {here} 件を表示中 · {missing} 件を追加", + "hint": "このセッションが開いたセッションを横に並べます", + "moveTitle": { + "other": "委任先 {count} 件をここに並べますか?" + }, + "moveBody": { + "other": "委任先 {count} 件が別のレイアウトにあります。ここに並べるとそこから外れます。セッションは動き続けます。" + } } } diff --git a/native/i18n/locales/ko/layouts.json b/native/i18n/locales/ko/layouts.json index 1fd4f625..a442721c 100644 --- a/native/i18n/locales/ko/layouts.json +++ b/native/i18n/locales/ko/layouts.json @@ -30,5 +30,18 @@ "top": "위로 분할", "bottom": "아래로 분할", "swap": "바꾸기" + }, + "delegates": { + "showBeside": { + "other": "위임 {count}개 · 옆에 보기" + }, + "add": "{count}개 중 {here}개 표시 중 · {missing}개 추가", + "hint": "이 세션이 연 세션들을 옆에 배치합니다", + "moveTitle": { + "other": "위임 {count}개를 여기에 배치할까요?" + }, + "moveBody": { + "other": "위임 {count}개가 다른 레이아웃에 있습니다. 여기에 배치하면 거기서 빠집니다. 세션은 계속 실행됩니다." + } } } diff --git a/native/i18n/locales/pt-br/layouts.json b/native/i18n/locales/pt-br/layouts.json index d7f565c6..d17799ad 100644 --- a/native/i18n/locales/pt-br/layouts.json +++ b/native/i18n/locales/pt-br/layouts.json @@ -32,5 +32,21 @@ "top": "Dividir acima", "bottom": "Dividir abaixo", "swap": "Trocar" + }, + "delegates": { + "showBeside": { + "one": "{count} delegado · Mostrar ao lado", + "other": "{count} delegados · Mostrar ao lado" + }, + "add": "{here} de {count} aqui · Adicionar {missing}", + "hint": "Coloca ao lado as sessões que esta abriu", + "moveTitle": { + "one": "Trazer {count} delegado para cá?", + "other": "Trazer {count} delegados para cá?" + }, + "moveBody": { + "one": "{count} delegado está em outro layout. Trazer para cá tira a sessão de lá; ela continua rodando.", + "other": "{count} delegados estão em outros layouts. Trazer para cá tira as sessões de lá; elas continuam rodando." + } } } diff --git a/native/i18n/locales/ru/layouts.json b/native/i18n/locales/ru/layouts.json index f0c77735..97a79400 100644 --- a/native/i18n/locales/ru/layouts.json +++ b/native/i18n/locales/ru/layouts.json @@ -36,5 +36,27 @@ "top": "Разделить сверху", "bottom": "Разделить снизу", "swap": "Поменять местами" + }, + "delegates": { + "showBeside": { + "one": "{count} делегат · Показать рядом", + "few": "{count} делегата · Показать рядом", + "many": "{count} делегатов · Показать рядом", + "other": "{count} делегата · Показать рядом" + }, + "add": "{here} из {count} здесь · Добавить {missing}", + "hint": "Разместить рядом сессии, которые открыла эта", + "moveTitle": { + "one": "Разместить здесь {count} делегата?", + "few": "Разместить здесь {count} делегатов?", + "many": "Разместить здесь {count} делегатов?", + "other": "Разместить здесь {count} делегата?" + }, + "moveBody": { + "one": "{count} делегат в другой раскладке. Если разместить его здесь, он уйдёт оттуда; сессия продолжит работать.", + "few": "{count} делегата в других раскладках. Если разместить их здесь, они уйдут оттуда; сессии продолжат работать.", + "many": "{count} делегатов в других раскладках. Если разместить их здесь, они уйдут оттуда; сессии продолжат работать.", + "other": "{count} делегата в других раскладках. Если разместить их здесь, они уйдут оттуда; сессии продолжат работать." + } } } diff --git a/native/i18n/locales/zh-cn/layouts.json b/native/i18n/locales/zh-cn/layouts.json index 84fd1db2..00d2f4f1 100644 --- a/native/i18n/locales/zh-cn/layouts.json +++ b/native/i18n/locales/zh-cn/layouts.json @@ -30,5 +30,18 @@ "top": "向上分割", "bottom": "向下分割", "swap": "交换" + }, + "delegates": { + "showBeside": { + "other": "{count} 个委派 · 并排显示" + }, + "add": "已显示 {here}/{count} · 添加 {missing} 个", + "hint": "把此会话打开的会话并排放在旁边", + "moveTitle": { + "other": "把 {count} 个委派放到这里?" + }, + "moveBody": { + "other": "有 {count} 个委派在其他布局中。放到这里会把它们从那里移出;会话会继续运行。" + } } }