diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e72d022..3784e928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The native window's topbar is now its title bar: the system's bar is gone, and minimize, maximize and close sit beside Settings, drawn the way Windows, GNOME and KDE draw them and on the side your desktop puts them; on macOS the traffic lights sit in the topbar. Drag the empty part of the topbar to move the window, and double-click it to maximize. Tiling window managers keep their own decorations. + ### Fixed - `lich quit` no longer fails with "EOF" when lich quits before answering: it waits for the port to close instead of calling the lost reply a refusal. diff --git a/docs/ceilings.md b/docs/ceilings.md index 01ccf2a3..79d72727 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -1280,6 +1280,20 @@ work when nobody knows it and that the call site never shows. The mechanism and Every window started either way prints into `lich-native.log` beside `lich.log`, where an event loop stuck on one event for 3s or more is reported with every goroutine's stack (`native/cmd/lich-native/stall.go`); a stall inside `w.Event()` itself, in Gio's own dispatch, looks like an idle window and is not reported. +- **The native window draws its own title bar, and a few desktops keep theirs** + (`native/cmd/lich-native/caption*.go`, the Gio fork's `app/caption_unix.go` and `app/os_x11_caption.go`): + the topbar is the title bar, with the window's buttons drawn by lich in each desktop's look (Windows 11, + GNOME's round buttons on the side and in the order of `button-layout`, Breeze under Plasma) and macOS's own + traffic lights in a 78dp reserve. A desktop that decorates its windows itself keeps its bar and lich draws no + buttons: on Wayland, a compositor that answers the request for client-side decorations with server-side + (KWin was measured granting client-side; what Hyprland, sway and niri answer was not measured); on X11, a window manager whose `_NET_WM_NAME` is on the fork's + tiling list (`tilingWMs`), so a tiling window manager missing from it gets buttons it cannot use. Under + Plasma the buttons are always Breeze's, whatever decoration theme is set. `button-layout` is read once, at + start, and only GNOME's portal key. On X11 the window has square corners and no shadow (Gio's surface has + no alpha), and a WM with no `_NET_WM_MOVERESIZE` cannot move it. Windows 11's Snap Layouts flyout does not + open over Maximize (it needs `HTMAXBUTTON` from the fork's hit test). On macOS the traffic lights sit where + AppKit puts them in its 28pt title bar, not centered on the 44dp topbar, and a double click on the empty + strip does not zoom; neither was looked at on a Mac. The web window keeps the system's title bar. - **The native window finds fonts through fontconfig on Linux and by scanning the font folders on Windows and macOS** (`native/fonts/locate_fontconfig.go`, `native/fonts/locate_fontscan.go`): neither ships fontconfig, and a window that shelled out to `fc-match` there closed at launch. The scan (go-text's fontscan) marks a diff --git a/native/cmd/lich-native/caption.go b/native/cmd/lich-native/caption.go new file mode 100644 index 00000000..8e9dd452 --- /dev/null +++ b/native/cmd/lich-native/caption.go @@ -0,0 +1,274 @@ +package main + +import ( + "image" + "image/color" + "strings" + + "gioui.org/app" + "gioui.org/io/system" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" + "github.com/lichdotdev/lich/native/ui/icons" +) + +// captionButton is one of the window's own buttons, drawn in the topbar. +type captionButton int + +const ( + captionMinimize captionButton = iota + captionMaximize + captionClose + captionButtons +) + +// captionStyle is whose look the window's buttons copy. +type captionStyle int + +const ( + captionStyleWindows captionStyle = iota + captionStyleGNOME + captionStyleKDE +) + +// captionLayout is where the topbar puts the window's buttons and how it +// draws them: the platform's own arrangement. +type captionLayout struct { + left, right []captionButton + style captionStyle + // leftReserve is room at the band's left the system draws its own + // buttons in (macOS's traffic lights), on top of lich's content. + leftReserve unit.Dp +} + +// windowChrome is what the window manager last told about the window. +type windowChrome struct { + // decorated: the system draws the window's caption (a tiling window + // manager, or a platform lich leaves it to), so the topbar adds none. + decorated bool + maximized bool + fullscreen bool +} + +func chromeOf(c app.Config) windowChrome { + return windowChrome{decorated: c.Decorated, maximized: c.Mode == app.Maximized, fullscreen: c.Mode == app.Fullscreen} +} + +// drawn reports whether the topbar draws the caption's buttons and reserve. +func (c windowChrome) drawn() bool { + return !c.decorated && !c.fullscreen +} + +// defaultButtonLayout is what a desktop without the setting gets: the three +// buttons on the right, as KDE and Pop!_OS place them. +const defaultButtonLayout = ":minimize,maximize,close" + +// parseButtonLayout reads GNOME's button-layout, "left:right" with +// comma-separated names, keeping the three buttons lich draws in their order +// and dropping the rest (appmenu, icon, spacer). +func parseButtonLayout(s string) (left, right []captionButton) { + l, r, _ := strings.Cut(s, ":") + return buttonNames(l), buttonNames(r) +} + +func buttonNames(s string) []captionButton { + var out []captionButton + for name := range strings.SplitSeq(s, ",") { + switch strings.TrimSpace(name) { + case "minimize": + out = append(out, captionMinimize) + case "maximize": + out = append(out, captionMaximize) + case "close": + out = append(out, captionClose) + } + } + return out +} + +// The buttons' geometry, from the approved titlebar mockup. +var ( + windowsButtonWidth = unit.Dp(46) // Windows 11's caption buttons + windowsGlyph = ui.Space(3) + gnomeButton = ui.Space(6) // libadwaita's 24px round buttons + gnomeGap = unit.Dp(10) + gnomeGlyph = ui.Space(3.5) + kdeButton = unit.Dp(22) // Breeze's buttons + kdeGap = ui.Space(1.5) + kdeGlyph = ui.Space(4) + captionSeparator = ui.Space(5) +) + +const ( + captionHoverFill = 0.09 + captionPressedFill = 0.15 + // The fill under GNOME's round buttons at rest. + gnomeRestFill = 0.09 +) + +var ( + // windowsCloseHover is the red Windows 11 paints under a hovered Close, + // whatever the theme. + windowsCloseHover = color.NRGBA{R: 0xc4, G: 0x2b, B: 0x1c, A: 0xff} + onCloseHover = color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} +) + +var ( + icMinimize = icons.Lucide("minus") + icMaximize = icons.Lucide("square") + icRestore = icons.Lucide("copy") + icCaptionX = icons.Lucide("x") + icKDEMinimize = icons.Lucide("chevron-down") + icKDEMaximize = icons.Lucide("chevron-up") + icKDERestore = icons.Lucide("diamond") +) + +// captionControls are the window's buttons in the topbar. +type captionControls struct { + clicks [captionButtons]widget.Clickable + hints [captionButtons]ui.Tooltip +} + +// layout draws buttons in a row the band's height, in style, and reports a +// button clicked this frame. +func (c *captionControls) layout(gtx C, th *ui.Theme, root *ui.Root, style captionStyle, buttons []captionButton, chrome windowChrome) (D, captionButton, bool) { + clicked, ok := captionButton(0), false + children := make([]layout.FlexChild, 0, 2*len(buttons)) + for i, b := range buttons { + if c.clicks[b].Clicked(gtx) { + clicked, ok = b, true + } + if i > 0 { + children = append(children, layout.Rigid(layout.Spacer{Width: captionGap(style)}.Layout)) + } + children = append(children, layout.Rigid(func(gtx C) D { + tip := th.Tooltip(root, &c.hints[b], captionLabel(b, chrome)) + tip.Side = ui.SideBottom + return tip.Layout(gtx, func(gtx C) D { return c.button(gtx, th, style, b, chrome) }) + })) + } + d := layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...) + return d, clicked, ok +} + +func captionGap(style captionStyle) unit.Dp { + switch style { + case captionStyleGNOME: + return gnomeGap + case captionStyleKDE: + return kdeGap + } + return 0 +} + +func captionLabel(b captionButton, chrome windowChrome) string { + switch b { + case captionMinimize: + return i18n.T("shell.windowControls.minimize") + case captionMaximize: + if chrome.maximized { + return i18n.T("shell.windowControls.restore") + } + return i18n.T("shell.windowControls.maximize") + } + return i18n.T("shell.windowControls.close") +} + +func (c *captionControls) button(gtx C, th *ui.Theme, style captionStyle, b captionButton, chrome windowChrome) D { + click := &c.clicks[b] + return click.Layout(gtx, func(gtx C) D { + switch style { + case captionStyleWindows: + return windowsButton(gtx, th, click, b, chrome) + case captionStyleKDE: + return kdeButtonFace(gtx, th, click, b, chrome) + } + return gnomeButtonFace(gtx, th, click, b, chrome) + }) +} + +// windowsButton is a 46-wide caption button the band's full height. +func windowsButton(gtx C, th *ui.Theme, click *widget.Clickable, b captionButton, chrome windowChrome) D { + gtx.Constraints = layout.Exact(image.Pt(gtx.Dp(windowsButtonWidth), gtx.Constraints.Max.Y)) + fg := th.Foreground + switch { + case b == captionClose && (click.Hovered() || click.Pressed()): + ui.Fill(gtx, windowsCloseHover, 0) + fg = onCloseHover + case click.Pressed(): + ui.Fill(gtx, th.Over(ui.Alpha(th.Foreground, captionPressedFill)), 0) + case click.Hovered(): + ui.Fill(gtx, th.Over(ui.Alpha(th.Foreground, captionHoverFill)), 0) + } + return layout.Center.Layout(gtx, func(gtx C) D { return captionIcon(captionStyleWindows, b, chrome).Layout(gtx, windowsGlyph, fg) }) +} + +// gnomeButtonFace is libadwaita's round button: a faint disc at rest. +func gnomeButtonFace(gtx C, th *ui.Theme, click *widget.Clickable, b captionButton, chrome windowChrome) D { + size := gtx.Dp(gnomeButton) + gtx.Constraints = layout.Exact(image.Pt(size, size)) + fill := float32(gnomeRestFill) + if click.Hovered() || click.Pressed() { + fill = captionPressedFill + } + ui.Fill(gtx, th.Over(ui.Alpha(th.Foreground, fill)), gnomeButton/2) + return layout.Center.Layout(gtx, func(gtx C) D { return captionIcon(captionStyleGNOME, b, chrome).Layout(gtx, gnomeGlyph, th.Foreground) }) +} + +// kdeButtonFace is Breeze's bare glyph; Close gets the red disc on hover. +func kdeButtonFace(gtx C, th *ui.Theme, click *widget.Clickable, b captionButton, chrome windowChrome) D { + size := gtx.Dp(kdeButton) + gtx.Constraints = layout.Exact(image.Pt(size, size)) + fg := th.Foreground + hot := click.Hovered() || click.Pressed() + switch { + case hot && b == captionClose: + ui.Fill(gtx, th.Destructive, kdeButton/2) + fg = onCloseHover + case hot: + ui.Fill(gtx, th.Over(ui.Alpha(th.Foreground, captionPressedFill)), kdeButton/2) + } + return layout.Center.Layout(gtx, func(gtx C) D { return captionIcon(captionStyleKDE, b, chrome).Layout(gtx, kdeGlyph, fg) }) +} + +// captionIcon is b's glyph in style; Maximize turns into Restore while the +// window is maximized. +func captionIcon(style captionStyle, b captionButton, chrome windowChrome) *icons.Icon { + kde := style == captionStyleKDE + switch { + case b == captionClose: + return icCaptionX + case b == captionMinimize && kde: + return icKDEMinimize + case b == captionMinimize: + return icMinimize + case chrome.maximized && kde: + return icKDERestore + case chrome.maximized: + return icRestore + case kde: + return icKDEMaximize + } + return icMaximize +} + +// performCaption carries out a click on b. Close goes through the window +// manager's own close, so onClosing answers it as it answers any other. +func (u *window) performCaption(b captionButton) { + switch b { + case captionMinimize: + u.win.Option(app.Minimized.Option()) + case captionMaximize: + mode := app.Maximized + if u.chrome.maximized { + mode = app.Windowed + } + u.win.Option(mode.Option()) + case captionClose: + u.win.Perform(system.ActionClose) + } +} diff --git a/native/cmd/lich-native/caption_darwin.go b/native/cmd/lich-native/caption_darwin.go new file mode 100644 index 00000000..ba23bd5c --- /dev/null +++ b/native/cmd/lich-native/caption_darwin.go @@ -0,0 +1,11 @@ +package main + +import "gioui.org/unit" + +// trafficLightsReserve is the room macOS's traffic lights take at the +// window's left, the inset Electron's hiddenInset title bar leaves them. +const trafficLightsReserve = unit.Dp(78) + +func platformCaption() captionLayout { + return captionLayout{leftReserve: trafficLightsReserve} +} diff --git a/native/cmd/lich-native/caption_linux.go b/native/cmd/lich-native/caption_linux.go new file mode 100644 index 00000000..1aed2a32 --- /dev/null +++ b/native/cmd/lich-native/caption_linux.go @@ -0,0 +1,46 @@ +package main + +import ( + "log" + "os" + "strings" + + "github.com/godbus/dbus/v5" +) + +const ( + wmPreferencesNS = "org.gnome.desktop.wm.preferences" + buttonLayoutKey = "button-layout" +) + +// platformCaption reads the desktop's button order from the settings +// portal, and draws the buttons in KDE's look under Plasma, GNOME's anywhere +// else. +func platformCaption() captionLayout { + style := captionStyleGNOME + if strings.Contains(os.Getenv("XDG_CURRENT_DESKTOP"), "KDE") { + style = captionStyleKDE + } + left, right := parseButtonLayout(buttonLayout()) + return captionLayout{left: left, right: right, style: style} +} + +func buttonLayout() string { + conn, err := dbus.SessionBus() + if err != nil { + log.Printf("caption: no session bus, buttons on the right: %v", err) + return defaultButtonLayout + } + var v dbus.Variant + call := conn.Object(portalService, portalPath).Call(portalSettings+".ReadOne", 0, wmPreferencesNS, buttonLayoutKey) + if err := call.Store(&v); err != nil { + log.Printf("caption: no button-layout in the settings portal, buttons on the right: %v", err) + return defaultButtonLayout + } + s, ok := v.Value().(string) + if !ok { + log.Printf("caption: button-layout is %v, buttons on the right", v) + return defaultButtonLayout + } + return s +} diff --git a/native/cmd/lich-native/caption_other.go b/native/cmd/lich-native/caption_other.go new file mode 100644 index 00000000..387987f7 --- /dev/null +++ b/native/cmd/lich-native/caption_other.go @@ -0,0 +1,8 @@ +//go:build !linux && !windows && !darwin + +package main + +func platformCaption() captionLayout { + left, right := parseButtonLayout(defaultButtonLayout) + return captionLayout{left: left, right: right, style: captionStyleGNOME} +} diff --git a/native/cmd/lich-native/caption_test.go b/native/cmd/lich-native/caption_test.go new file mode 100644 index 00000000..995945c1 --- /dev/null +++ b/native/cmd/lich-native/caption_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "slices" + "testing" + + "gioui.org/app" +) + +func TestParseButtonLayout(t *testing.T) { + cases := []struct { + name string + layout string + left, right []captionButton + }{ + {"stock GNOME keeps only close", "appmenu:close", nil, []captionButton{captionClose}}, + {"Pop!_OS", "appmenu:minimize,maximize,close", nil, []captionButton{captionMinimize, captionMaximize, captionClose}}, + {"macOS-like left", "close,minimize,maximize:", []captionButton{captionClose, captionMinimize, captionMaximize}, nil}, + {"spacers and icons dropped", "icon, close :spacer,minimize", []captionButton{captionClose}, []captionButton{captionMinimize}}, + {"no colon is all left", "close", []captionButton{captionClose}, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + left, right := parseButtonLayout(c.layout) + if !slices.Equal(left, c.left) || !slices.Equal(right, c.right) { + t.Errorf("parseButtonLayout(%q) = %v, %v; want %v, %v", c.layout, left, right, c.left, c.right) + } + }) + } +} + +func TestWindowChromeDrawn(t *testing.T) { + cases := []struct { + name string + cfg app.Config + want bool + }{ + {"lich draws the caption", app.Config{Mode: app.Windowed}, true}, + {"maximized keeps it", app.Config{Mode: app.Maximized}, true}, + {"a tiling window manager decorates", app.Config{Mode: app.Windowed, Decorated: true}, false}, + {"fullscreen has none", app.Config{Mode: app.Fullscreen}, false}, + } + for _, c := range cases { + if got := chromeOf(c.cfg).drawn(); got != c.want { + t.Errorf("%s: drawn() = %v, want %v", c.name, got, c.want) + } + } +} + +func TestCaptionIconRestoresWhileMaximized(t *testing.T) { + maximized := windowChrome{maximized: true} + if captionIcon(captionStyleWindows, captionMaximize, maximized) != icRestore { + t.Error("Windows: a maximized window's Maximize is not Restore") + } + if captionIcon(captionStyleKDE, captionMaximize, maximized) != icKDERestore { + t.Error("KDE: a maximized window's Maximize is not Restore") + } + if captionIcon(captionStyleGNOME, captionMaximize, windowChrome{}) != icMaximize { + t.Error("GNOME: a windowed window's Maximize is not Maximize") + } +} diff --git a/native/cmd/lich-native/caption_windows.go b/native/cmd/lich-native/caption_windows.go new file mode 100644 index 00000000..c875c549 --- /dev/null +++ b/native/cmd/lich-native/caption_windows.go @@ -0,0 +1,5 @@ +package main + +func platformCaption() captionLayout { + return captionLayout{right: []captionButton{captionMinimize, captionMaximize, captionClose}, style: captionStyleWindows} +} diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 11cf516d..0624e020 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -71,7 +71,7 @@ func main() { go func() { width, height := windowSize(*flagWindow) w := new(app.Window) - w.Option(app.Title("lich (native slice)"), app.Size(width, height)) + w.Option(app.Title("lich (native slice)"), app.Size(width, height), app.Decorated(false)) code := 0 if err := run(w, rt); err != nil { log.Print(err) @@ -117,6 +117,10 @@ type window struct { // duplicates holds the warnings about a conversation resumed in two // cards, in the bottom-right corner, apart from every other toast. duplicates ui.Toaster + // caption is where the topbar draws the window's buttons, chrome what + // the window manager last said about the window. + caption captionLayout + chrome windowChrome // shortcuts is the keyboard shortcuts overlay. shortcuts shortcutsOverlay // palette is the command palette. @@ -195,7 +199,7 @@ func run(w *app.Window, rt lichclient.Runtime) error { th.Fg, th.Bg = ui.Dark.Foreground, ui.Dark.Background u := &window{ - ctx: ctx, client: client, th: th, win: w, zoom: defaultZoom, + ctx: ctx, client: client, th: th, win: w, zoom: defaultZoom, caption: platformCaption(), theme: &ui.Theme{Palette: ui.Dark, Shaper: th.Shaper, Sans: fonts.Sans, Mono: faces[0].Font.Typeface, Surface: ui.Dark.Canvas}, model: newModel(client, w.Invalidate), terms: &terminals{client: client, invalidate: w.Invalidate, renderer: *flagRenderer, family: *flagFont, @@ -301,6 +305,7 @@ func run(w *app.Window, rt lichclient.Runtime) error { case app.DestroyEvent: return e.Err case app.ConfigEvent: + u.chrome = chromeOf(e.Config) u.model.setFocused(u.ctx, e.Config.Focused) u.toaster.SetHidden(!e.Config.Focused) case *app.ClosingEvent: @@ -687,6 +692,7 @@ func (u *window) topbarFrame(gtx C, v stageView, edge int) topbarFrame { f := topbarFrame{ brandWidth: width, brandName: name, stripX: edge, island: u.islandColor(v), tabs: v.strip, active: v.stripActive, pending: u.model.notifications(), + caption: u.caption, chrome: u.chrome, } // Settings is no project's screen, so no tab merges into it. if u.settings.open { diff --git a/native/cmd/lich-native/topbar.go b/native/cmd/lich-native/topbar.go index b859f4a3..a228dd3a 100644 --- a/native/cmd/lich-native/topbar.go +++ b/native/cmd/lich-native/topbar.go @@ -9,6 +9,7 @@ import ( "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" + "gioui.org/unit" "gioui.org/widget" "github.com/lichdotdev/lich/native/i18n" @@ -65,6 +66,11 @@ type topbar struct { settings widget.Clickable pulls widget.Clickable confirm closeConfirm + controls captionControls + // shift is how far right of the terminal island's edge the strip starts: + // past the window's own buttons on the left when the sidebar is too + // narrow to hold them and the brand. + shift int } func newTopbar(root *ui.Root) *topbar { @@ -90,6 +96,9 @@ type topbarAction struct { pulls bool // the Pull requests button // renamed is a layout tab's new name, committed this frame. renamed layoutRename + // caption is a window button clicked, when captionClicked. + caption captionButton + captionClicked bool } // layoutRename is a layout's new name. @@ -110,6 +119,8 @@ type topbarFrame struct { pending []notification settings bool // the Settings screen is open pulls bool // the Pulls screen is open + caption captionLayout + chrome windowChrome } // islandSpan is one island under the tab strip: its left and right edges in @@ -129,7 +140,11 @@ func (t *topbar) layout(gtx C, th *ui.Theme, f topbarFrame) topbarAction { } g := gtx.Dp(gutter) right := t.rightSide(gtx, th, f, &act) - t.place(gtx, image.Pt(g, 0), func(gtx C) D { return t.brandButton(gtx, th, f) }) + left := t.leftSide(gtx, th, f, &act) + f.brandWidth = max(f.brandWidth-left, gtx.Dp(brandHeight)) + start := max(f.stripX, g+left+f.brandWidth+gtx.Dp(barGap)) + t.shift, f.stripX = start-f.stripX, start + t.place(gtx, image.Pt(g+left, 0), func(gtx C) D { return t.brandButton(gtx, th, f) }) stripW := gtx.Constraints.Max.X - right - g - gtx.Dp(barGap) - f.stripX t.place(gtx, image.Pt(f.stripX, 0), func(gtx C) D { gtx.Constraints = layout.Exact(image.Pt(max(stripW, 0), gtx.Constraints.Max.Y)) @@ -150,20 +165,70 @@ func (t *topbar) rightSide(gtx C, th *ui.Theme, f topbarFrame, act *topbarAction macro := op.Record(gtx.Ops) row := gtx row.Constraints.Min = image.Point{} - d := layout.Flex{Alignment: layout.Middle}.Layout(row, + children := []layout.FlexChild{ layout.Rigid(func(gtx C) D { return t.bellButton(gtx, th, f.pending, act) }), ui.Gap(1), layout.Rigid(func(gtx C) D { return t.pullsButton(gtx, th, f.pulls, act) }), ui.Gap(1), layout.Rigid(func(gtx C) D { return t.settingsButton(gtx, th, f.settings, act) }), - ) + } + pad := gtx.Dp(gutter) + if f.chrome.drawn() && len(f.caption.right) > 0 { + children = append(children, + layout.Rigid(func(gtx C) D { return captionDivider(gtx, th) }), + layout.Rigid(func(gtx C) D { return t.captionButtons(gtx, th, f, f.caption.right, act) })) + // Windows' buttons sit in the window's corner, as its own do. + if f.caption.style == captionStyleWindows { + pad = 0 + } + } + d := layout.Flex{Alignment: layout.Middle}.Layout(row, children...) call := macro.Stop() - x := gtx.Constraints.Max.X - gtx.Dp(gutter) - d.Size.X + x := gtx.Constraints.Max.X - pad - d.Size.X defer op.Offset(image.Pt(x, (gtx.Constraints.Max.Y-d.Size.Y)/2)).Push(gtx.Ops).Pop() call.Add(gtx.Ops) return d.Size.X } +// leftSide draws the window's buttons the desktop puts on the left, or +// leaves room for the system's own, and answers how wide that is. +func (t *topbar) leftSide(gtx C, th *ui.Theme, f topbarFrame, act *topbarAction) int { + if !f.chrome.drawn() { + return 0 + } + if len(f.caption.left) == 0 { + return gtx.Dp(f.caption.leftReserve) + } + macro := op.Record(gtx.Ops) + row := gtx + row.Constraints.Min = image.Point{} + d := layout.Flex{Alignment: layout.Middle}.Layout(row, + layout.Rigid(func(gtx C) D { return t.captionButtons(gtx, th, f, f.caption.left, act) }), + layout.Rigid(func(gtx C) D { return captionDivider(gtx, th) }), + ) + call := macro.Stop() + defer op.Offset(image.Pt(gtx.Dp(gutter), (gtx.Constraints.Max.Y-d.Size.Y)/2)).Push(gtx.Ops).Pop() + call.Add(gtx.Ops) + return d.Size.X +} + +func (t *topbar) captionButtons(gtx C, th *ui.Theme, f topbarFrame, buttons []captionButton, act *topbarAction) D { + d, b, ok := t.controls.layout(gtx, th, t.root, f.caption.style, buttons, f.chrome) + if ok { + act.caption, act.captionClicked = b, true + } + return d +} + +// captionDivider is the rule between lich's own buttons and the window's. +func captionDivider(gtx C, th *ui.Theme) D { + return layout.Inset{Left: gutter, Right: gutter}.Layout(gtx, func(gtx C) D { + size := image.Pt(gtx.Dp(unit.Dp(1)), gtx.Dp(captionSeparator)) + paint.FillShape(gtx.Ops, th.Over(th.Border), clip.Rect{Max: size}.Op()) + return D{Size: size} + }) +} + // brandButton is the mark and the name over the sidebar's column, a // rounded-md button to Home, clipped to the column as it narrows. func (t *topbar) brandButton(gtx C, th *ui.Theme, f topbarFrame) D { diff --git a/native/cmd/lich-native/topbar_apply.go b/native/cmd/lich-native/topbar_apply.go index 72fc10fb..5e9c550b 100644 --- a/native/cmd/lich-native/topbar_apply.go +++ b/native/cmd/lich-native/topbar_apply.go @@ -54,6 +54,11 @@ func closeBody(name string, running int) string { // applyTopbar carries out what the strip's clicks asked for. func (u *window) applyTopbar(act topbarAction) { u.applySettingsAction(act) + if act.captionClicked { + // Off the frame: Window.Option waits on the event loop this frame + // is holding. + go u.performCaption(act.caption) + } if act.home || act.show != "" { u.pulls.hide() } diff --git a/native/cmd/lich-native/topbar_tabs.go b/native/cmd/lich-native/topbar_tabs.go index 0b9abbc1..23a774a7 100644 --- a/native/cmd/lich-native/topbar_tabs.go +++ b/native/cmd/lich-native/topbar_tabs.go @@ -7,6 +7,7 @@ import ( "gioui.org/f32" "gioui.org/font" + "gioui.org/io/system" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" @@ -111,7 +112,7 @@ func (t *topbar) strip(gtx C, th *ui.Theme, f topbarFrame, act *topbarAction) D } size := gtx.Constraints.Max clipped := clip.Rect{Max: size}.Push(gtx.Ops) - t.list.Layout(gtx, 1, func(gtx C, _ int) D { + tabs := t.list.Layout(gtx, 1, func(gtx C, _ int) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Inset{Top: tabTop}.Layout(gtx, func(gtx C) D { @@ -128,6 +129,13 @@ func (t *topbar) strip(gtx C, th *ui.Theme, f topbarFrame, act *topbarAction) D ) }) t.fillets(gtx, f, w) + if f.chrome.drawn() { + // The strip's empty run drags the window, and a double click on it + // maximizes, as a title bar does. + area := clip.Rect{Min: image.Pt(tabs.Size.X, 0), Max: size}.Push(gtx.Ops) + system.ActionInputOp(system.ActionMove).Add(gtx.Ops) + area.Pop() + } clipped.Pop() return D{Size: size} } @@ -371,7 +379,7 @@ func (t *topbar) mergeSpan(gtx C, f topbarFrame) (islandSpan, bool) { return islandSpan{}, false } if len(f.islands) == 0 { - return islandSpan{x1: math.MaxInt, color: f.island}, true + return islandSpan{x0: -t.shift, x1: math.MaxInt, color: f.island}, true } r := gtx.Dp(tabRadius) left := at*t.tabW - t.list.Position.Offset @@ -426,6 +434,7 @@ func (t *topbar) hoverSquare(f topbarFrame) (x0 int, ok bool) { if st == nil || !(st.click.Hovered() || st.close.Hovered()) { return 0, false } + x0 = -t.shift if len(f.islands) > 0 { x0 = f.islands[0].x0 } diff --git a/native/go.mod b/native/go.mod index 0b25f673..11ec468a 100644 --- a/native/go.mod +++ b/native/go.mod @@ -27,6 +27,7 @@ require ( // lichdotdev/gio, branch drop-event, carries what gioui.org lacks: app.DropEvent // (external file drop), Window.Activate (raising the window on Wayland), word -// deletion with Ctrl off macOS, and Wayland fixes for EGL vsync, clipboard -// reads, pointer.Leave and the cursor (cursor-shape-v1). -replace gioui.org => github.com/lichdotdev/gio v0.0.0-20261019145824-c389a6eb0b04 +// deletion with Ctrl off macOS, Wayland fixes for EGL vsync, clipboard reads, +// pointer.Leave and the cursor (cursor-shape-v1), and an undecorated window +// that moves, resizes and maximizes on Wayland and X11. +replace gioui.org => github.com/lichdotdev/gio v0.0.0-20261019191456-c03974435341 diff --git a/native/go.sum b/native/go.sum index 89d59464..f0e25ba1 100644 --- a/native/go.sum +++ b/native/go.sum @@ -25,8 +25,8 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/lichdotdev/gio v0.0.0-20261019145824-c389a6eb0b04 h1:1UC6Js6PJf2epT9C3iV2drtQeeaY7MWBHYPpkjmJyCY= -github.com/lichdotdev/gio v0.0.0-20261019145824-c389a6eb0b04/go.mod h1:x8MAOooc/v4UUaB5o6BS/FpYdxnzh3LBvZlTBmYpnlQ= +github.com/lichdotdev/gio v0.0.0-20261019191456-c03974435341 h1:swlVHPBemrgFaFVQFHUmXncM/VpPujjAm0ik1XsTXLY= +github.com/lichdotdev/gio v0.0.0-20261019191456-c03974435341/go.mod h1:x8MAOooc/v4UUaB5o6BS/FpYdxnzh3LBvZlTBmYpnlQ= github.com/lichdotdev/godemirror v0.8.0 h1:sPnCNZ1JygGr6J4IoiT1Oe2tbv1HmrTbPXUDhC81T8I= github.com/lichdotdev/godemirror v0.8.0/go.mod h1:xCdFPbos7+4/bCymJIx7FnL4EpsuMpebRCbGM5Un4fg= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= diff --git a/native/i18n/locales/en/shell.json b/native/i18n/locales/en/shell.json index 118debf3..10d8468a 100644 --- a/native/i18n/locales/en/shell.json +++ b/native/i18n/locales/en/shell.json @@ -186,6 +186,12 @@ "quitFailed": "Could not quit lich: {error}", "countFailed": "Could not count the running sessions: {error}" }, + "windowControls": { + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close" + }, "reconnect": { "reconnecting": "Reconnecting to lich…" }, diff --git a/native/i18n/locales/es/shell.json b/native/i18n/locales/es/shell.json index efbeb3d2..f0d1385e 100644 --- a/native/i18n/locales/es/shell.json +++ b/native/i18n/locales/es/shell.json @@ -186,6 +186,12 @@ "quitFailed": "No se pudo salir de lich: {error}", "countFailed": "No se pudieron contar las sesiones en ejecución: {error}" }, + "windowControls": { + "minimize": "Minimizar", + "maximize": "Maximizar", + "restore": "Restaurar", + "close": "Cerrar" + }, "reconnect": { "reconnecting": "Reconectando con lich…" }, diff --git a/native/i18n/locales/ja/shell.json b/native/i18n/locales/ja/shell.json index 8d0efb70..45b72d23 100644 --- a/native/i18n/locales/ja/shell.json +++ b/native/i18n/locales/ja/shell.json @@ -186,6 +186,12 @@ "quitFailed": "lich を終了できませんでした: {error}", "countFailed": "実行中のセッション数を取得できませんでした: {error}" }, + "windowControls": { + "minimize": "最小化", + "maximize": "最大化", + "restore": "元に戻す", + "close": "閉じる" + }, "reconnect": { "reconnecting": "lich に再接続しています…" }, diff --git a/native/i18n/locales/ko/shell.json b/native/i18n/locales/ko/shell.json index bf4738d8..2a7e54a8 100644 --- a/native/i18n/locales/ko/shell.json +++ b/native/i18n/locales/ko/shell.json @@ -185,6 +185,12 @@ "quitFailed": "lich를 종료할 수 없습니다: {error}", "countFailed": "실행 중인 세션 수를 셀 수 없습니다: {error}" }, + "windowControls": { + "minimize": "최소화", + "maximize": "최대화", + "restore": "이전 크기로 복원", + "close": "닫기" + }, "reconnect": { "reconnecting": "lich에 다시 연결하는 중…" }, diff --git a/native/i18n/locales/pt-br/shell.json b/native/i18n/locales/pt-br/shell.json index d75d9a4f..46a0a334 100644 --- a/native/i18n/locales/pt-br/shell.json +++ b/native/i18n/locales/pt-br/shell.json @@ -186,6 +186,12 @@ "quitFailed": "Não foi possível encerrar o lich: {error}", "countFailed": "Não foi possível contar as sessões em execução: {error}" }, + "windowControls": { + "minimize": "Minimizar", + "maximize": "Maximizar", + "restore": "Restaurar", + "close": "Fechar" + }, "reconnect": { "reconnecting": "Reconectando ao lich…" }, diff --git a/native/i18n/locales/ru/shell.json b/native/i18n/locales/ru/shell.json index 1c88c7f6..a821ea8d 100644 --- a/native/i18n/locales/ru/shell.json +++ b/native/i18n/locales/ru/shell.json @@ -200,6 +200,12 @@ "quitFailed": "Не удалось выйти из lich: {error}", "countFailed": "Не удалось подсчитать запущенные сессии: {error}" }, + "windowControls": { + "minimize": "Свернуть", + "maximize": "Развернуть", + "restore": "Восстановить", + "close": "Закрыть" + }, "reconnect": { "reconnecting": "Переподключение к lich…" }, diff --git a/native/i18n/locales/zh-cn/shell.json b/native/i18n/locales/zh-cn/shell.json index debefdce..e0a2cc9a 100644 --- a/native/i18n/locales/zh-cn/shell.json +++ b/native/i18n/locales/zh-cn/shell.json @@ -186,6 +186,12 @@ "quitFailed": "无法退出 lich:{error}", "countFailed": "无法统计运行中的会话:{error}" }, + "windowControls": { + "minimize": "最小化", + "maximize": "最大化", + "restore": "还原", + "close": "关闭" + }, "reconnect": { "reconnecting": "正在重新连接 lich…" },