Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
274 changes: 274 additions & 0 deletions native/cmd/lich-native/caption.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 11 additions & 0 deletions native/cmd/lich-native/caption_darwin.go
Original file line number Diff line number Diff line change
@@ -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}
}
46 changes: 46 additions & 0 deletions native/cmd/lich-native/caption_linux.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions native/cmd/lich-native/caption_other.go
Original file line number Diff line number Diff line change
@@ -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}
}
Loading
Loading