From 0a0e55b1324a8daa1013356064d35e3c03d336e6 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sat, 4 Apr 2026 16:08:38 +0100 Subject: [PATCH 01/12] Add a new largetype launcher --- internal/ui/settings.go | 4 +- modules/launcher/init.go | 1 + modules/launcher/largetype.go | 131 +++++++++++++++++++++++++++++++++ modules/launcher/largetype.svg | 1 + 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 modules/launcher/largetype.go create mode 100644 modules/launcher/largetype.svg diff --git a/internal/ui/settings.go b/internal/ui/settings.go index f9bb29688..07e4a29ae 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -274,9 +274,9 @@ func (d *deskSettings) load() { d.launcherZoomScale = 2.0 } - defaultModules := "Battery|Brightness|Sound|Launcher: Calculate|Launcher: Convert units|Launcher: Open URLs|Network|Virtual Desktops|SystemTray|Terminal Overlay|Desktop Files" + defaultModules := "Battery|Brightness|Sound|Launcher: Calculate|Launcher: Convert units|Launcher: Large Type|Launcher: Open URLs|Network|Virtual Desktops|SystemTray|Terminal Overlay|Desktop Files" if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { // testing - defaultModules = "Battery|Brightness|Sound|Launcher: Calculate|Launcher: Open URLs|Network|Virtual Desktops" + defaultModules = "Battery|Brightness|Sound|Launcher: Calculate|Launcher: Large Type|Launcher: Open URLs|Network|Virtual Desktops" } moduleNames := fyne.CurrentApp().Preferences().StringWithFallback("modulenames", defaultModules) if moduleNames != "" { diff --git a/modules/launcher/init.go b/modules/launcher/init.go index b735d621b..ec60cc082 100644 --- a/modules/launcher/init.go +++ b/modules/launcher/init.go @@ -4,6 +4,7 @@ import "fyshos.com/fynedesk" func init() { fynedesk.RegisterModule(calcMeta) + fynedesk.RegisterModule(largeTypeMeta) fynedesk.RegisterModule(urlMeta) fynedesk.RegisterModule(unytsMeta) } diff --git a/modules/launcher/largetype.go b/modules/launcher/largetype.go new file mode 100644 index 000000000..b35673de9 --- /dev/null +++ b/modules/launcher/largetype.go @@ -0,0 +1,131 @@ +package launcher + +import ( + _ "embed" + "image/color" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "fyshos.com/fynedesk" +) + +var largeTypeAliases = []string{"largetype", "large", "big", "bigtype", "type"} + +var largeTypeMeta = fynedesk.ModuleMetadata{ + Name: "Launcher: Large Type", + NewInstance: newLargeType, +} + +//go:embed largetype.svg +var resourceLargeTypeSvgData []byte + +var resourceLargeType = &fyne.StaticResource{ + StaticName: "largetype.svg", + StaticContent: resourceLargeTypeSvgData, +} + +type largeType struct{} + +func (l *largeType) Destroy() { +} + +func (l *largeType) Metadata() fynedesk.ModuleMetadata { + return largeTypeMeta +} + +func (l *largeType) LaunchSuggestions(input string) []fynedesk.LaunchSuggestion { + lower := strings.ToLower(input) + + for _, alias := range largeTypeAliases { + prefix := alias + " " + if strings.HasPrefix(lower, prefix) { + text := input[len(prefix):] + return []fynedesk.LaunchSuggestion{&largeTypeItem{text: text}} + } + if strings.HasPrefix(alias, lower) { + return []fynedesk.LaunchSuggestion{&largeTypeItem{}} + } + } + + return nil +} + +func newLargeType() fynedesk.Module { + return &largeType{} +} + +type largeTypeItem struct { + text string +} + +func (i *largeTypeItem) Icon() fyne.Resource { + return theme.NewThemedResource(resourceLargeType) +} + +func (i *largeTypeItem) Title() string { + return "Large Type: " + i.text +} + +func (i *largeTypeItem) Launch() { + desk := fynedesk.Instance() + screen := desk.Screens().Primary() + scale := screen.CanvasScale() + + screenW := float32(screen.Width) / scale + screenH := float32(screen.Height) / scale + screenX := float32(screen.X) / scale + screenY := float32(screen.Y) / scale + + label := canvas.NewText(i.text, theme.Color(theme.ColorNameForeground)) + label.TextSize = 120 + label.Alignment = fyne.TextAlignCenter + label.TextStyle = fyne.TextStyle{Bold: true} + + r, g, b, _ := theme.Color(theme.ColorNameOverlayBackground).RGBA() + bg := canvas.NewRectangle(&color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xd0}) + + var overlay fyne.CanvasObject + dismiss := &largeTypeTappable{onTap: func() { + desk.HideOverlay(overlay) + }} + dismiss.ExtendBaseWidget(dismiss) + overlay = container.NewStack(bg, dismiss, container.NewCenter(label)) + + size := fyne.NewSize(screenW, screenH) + pos := fyne.NewPos(screenX, screenY) + desk.ShowOverlay(overlay, size, pos) + fyne.Do(func() { + desk.Root().Canvas().Focus(dismiss) + }) +} + +// largeTypeTappable is a transparent widget that dismisses the overlay on tap. +type largeTypeTappable struct { + widget.BaseWidget + onTap func() +} + +func (t *largeTypeTappable) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent)) +} + +func (t *largeTypeTappable) Tapped(_ *fyne.PointEvent) { + if t.onTap != nil { + t.onTap() + } +} + +func (t *largeTypeTappable) FocusGained() {} +func (t *largeTypeTappable) FocusLost() {} +func (t *largeTypeTappable) TypedRune(rune) {} + +func (t *largeTypeTappable) TypedKey(_ *fyne.KeyEvent) { + if t.onTap != nil { + t.onTap() + } +} diff --git a/modules/launcher/largetype.svg b/modules/launcher/largetype.svg new file mode 100644 index 000000000..48647fef2 --- /dev/null +++ b/modules/launcher/largetype.svg @@ -0,0 +1 @@ + \ No newline at end of file From ecb7b6dd6e9f7b69251d4ca02438d4686cf8798e Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Mon, 6 Apr 2026 13:07:46 +0100 Subject: [PATCH 02/12] Pull in Fyne develop so we can use a blur background :) --- go.mod | 7 ++++-- go.sum | 40 +++++++++++++++++++++++++++++++---- modules/launcher/largetype.go | 4 ++-- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 4b6ca6b28..2c2b4c78a 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.19 require ( codeberg.org/sdassow/unyts v0.4.1 - fyne.io/fyne/v2 v2.7.3 + fyne.io/fyne/v2 v2.7.4-0.20260406093139-56b2442450ac github.com/BurntSushi/toml v1.5.0 // indirect github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 @@ -45,18 +45,21 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 // indirect github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect + github.com/anthonynsimon/bild v0.13.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fredbi/uri v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fyne-io/gl-js v0.2.0 // indirect + github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect github.com/fyne-io/glfw-js v0.3.0 // indirect github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-text/render v0.2.1 // indirect github.com/go-text/typesetting v0.3.4 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/mattn/go-runewidth v0.0.17 // indirect github.com/pkg/errors v0.8.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/yuin/goldmark v1.7.8 // indirect diff --git a/go.sum b/go.sum index 70655f63b..e24879ec6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ codeberg.org/sdassow/unyts v0.4.1 h1:nLYkLu0E1tO9+6mKzGFf3aDaV0YSiR2msous9t5LQ0Y= codeberg.org/sdassow/unyts v0.4.1/go.mod h1:VlI7KnjFURzz7TQNla4308PgEUt4VVrfYbf0AGiqyoI= -fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE= -fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw= +fyne.io/fyne/v2 v2.7.4-0.20260406093139-56b2442450ac h1:R/yT+trqfgV8Tw5bkLmKgbIRGl3/H5BfsN1FCeDvv48= +fyne.io/fyne/v2 v2.7.4-0.20260406093139-56b2442450ac/go.mod h1:3FRSe4wPFsT9iFAH/HKZf6b7zqBQXupvIRJ9mTz/k90= fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/ActiveState/termtest/conpty v0.5.0 h1:JLUe6YDs4Jw4xNPCU+8VwTpniYOGeKzQg4SM2YHQNA8= @@ -12,6 +12,7 @@ github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 h1:1qlsVAQJ github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ= github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 h1:lTG4HQym5oPKjL7nGs+csTgiDna685ZXjxijkne828g= github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/FyshOS/appie v0.1.0 h1:B9CD2ZufCAPTFaE18fbUvuasAYC22bzoh4C8fmvXMno= @@ -28,8 +29,16 @@ github.com/FyshOS/saver v0.1.1-0.20260402091637-a576dfc113a3 h1:O+cisIWukjMLG3jM github.com/FyshOS/saver v0.1.1-0.20260402091637-a576dfc113a3/go.mod h1:WvBivsR68hbiahFFjEf5Yzv1chBd/Slo8TRziPgzEOY= github.com/Knetic/govaluate v3.0.0+incompatible h1:7o6+MAPhYTCF0+fdvoz1xDedhRb4f6s9Tn1Tt7/WTEg= github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8= +github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= @@ -37,10 +46,11 @@ github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44am github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= -github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8= +github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= @@ -69,6 +79,8 @@ github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQb github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jackmordaunt/icns v1.0.1-0.20200413110149-9e181b441ab2 h1:2bRhR5GcMudCdaY4p8ip89hsvSyxYehLSicCNtygyVY= github.com/jackmordaunt/icns v1.0.1-0.20200413110149-9e181b441ab2/go.mod h1:Hj3TV9xrdt+g9apvBagVi/VzE41gSliEBypxaQDq5QA= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= @@ -79,34 +91,53 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6U github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/mafik/pulseaudio v0.0.0-20200511091429-8449222912dd h1:gn6oLpDCn3wY+1WfbbP0OwxXL+2eHi3mxF/DsNyPHUM= github.com/mafik/pulseaudio v0.0.0-20200511091429-8449222912dd/go.mod h1:dlpd1fnLAhI6g9tM/aCobgN/Yka1/SkHrBTfAgDdb9Q= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200428200454-593003d681fa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -116,6 +147,7 @@ golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= diff --git a/modules/launcher/largetype.go b/modules/launcher/largetype.go index b35673de9..9d9635c19 100644 --- a/modules/launcher/largetype.go +++ b/modules/launcher/largetype.go @@ -87,14 +87,14 @@ func (i *largeTypeItem) Launch() { label.TextStyle = fyne.TextStyle{Bold: true} r, g, b, _ := theme.Color(theme.ColorNameOverlayBackground).RGBA() - bg := canvas.NewRectangle(&color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xd0}) + bg := canvas.NewRectangle(&color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0x80}) var overlay fyne.CanvasObject dismiss := &largeTypeTappable{onTap: func() { desk.HideOverlay(overlay) }} dismiss.ExtendBaseWidget(dismiss) - overlay = container.NewStack(bg, dismiss, container.NewCenter(label)) + overlay = container.NewStack(canvas.NewBlur(5), bg, dismiss, container.NewCenter(label)) size := fyne.NewSize(screenW, screenH) pos := fyne.NewPos(screenX, screenY) From aec915907d82d96044d380d65ff6b3ff1036b32c Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Tue, 7 Apr 2026 09:40:17 +0100 Subject: [PATCH 03/12] Fix launcher position if external monitor above primary --- internal/ui/launcher.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/launcher.go b/internal/ui/launcher.go index f9eb7843f..f0c45458d 100644 --- a/internal/ui/launcher.go +++ b/internal/ui/launcher.go @@ -242,13 +242,13 @@ func (l *picker) show() { primary := d.Screens().Primary() scale := primary.CanvasScale() originX, originY := screenOrigin(d.Screens()) - pW := float32(primary.X-originX+primary.Width) / scale - pH := float32(primary.Y-originY+primary.Height) / scale + midX := float32(primary.X-originX+primary.Width/2) / scale + midY := float32(primary.Y-originY+primary.Height/2) / scale entryHeight := l.entry.MinSize().Height l.fullSize = fyne.NewSize(launcherWidth, entryHeight*float32(launcherMaxResults+1)+pad*float32(launcherMaxResults+4)-3) - pos := fyne.NewPos((pW-l.fullSize.Width)/2, (pH-l.fullSize.Height)/2) + pos := fyne.NewPos(midX-(l.fullSize.Width)/2, midY-(l.fullSize.Height)/2) // Start bg at entry-only height l.bg.Resize(fyne.NewSize(l.fullSize.Width, entryHeight+pad*2)) From 336639a21571ea68ce36eb87a72a28cfcdb56ab4 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Tue, 7 Apr 2026 21:13:23 +0100 Subject: [PATCH 04/12] Use a different root window for each screen Makes for better performance when redrawing a desktop with many monitors --- go.mod | 2 +- go.sum | 4 +- internal/ui/background.go | 61 +--- internal/ui/compositor.go | 21 +- internal/ui/desk.go | 277 ++++++++++----- internal/ui/desk_embed.go | 4 +- internal/ui/desk_full.go | 26 +- internal/ui/launcher.go | 5 +- internal/ui/menu.go | 24 +- internal/ui/notifications.go | 5 +- internal/ui/switcher.go | 5 +- internal/x11/composit/gocompositor.go | 493 ++++++++++++++++++-------- internal/x11/win/client.go | 14 +- internal/x11/win/frame.go | 1 + internal/x11/wm/desk.go | 186 +++++++--- internal/x11/wm/stack.go | 2 +- internal/x11/xwm.go | 1 + modules/quaketerm/term.go | 20 +- 18 files changed, 737 insertions(+), 414 deletions(-) diff --git a/go.mod b/go.mod index 2c2b4c78a..925eeb6c3 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/FyshOS/backgrounds v0.1.0 github.com/FyshOS/fancyfs v0.0.1 // indirect github.com/FyshOS/fyles v0.1.0 - github.com/FyshOS/saver v0.1.1-0.20260402091637-a576dfc113a3 + github.com/FyshOS/saver v0.1.1-0.20260407200543-762135717028 github.com/Knetic/govaluate v3.0.0+incompatible github.com/disintegration/imaging v1.6.2 github.com/fyne-io/image v0.1.1 diff --git a/go.sum b/go.sum index e24879ec6..df18d6ff7 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,8 @@ github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg= github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A= github.com/FyshOS/fyles v0.1.0 h1:ezJIxIEcQtoyN8c52vadLZRr1m8ZUIusz8yBfjed/pk= github.com/FyshOS/fyles v0.1.0/go.mod h1:YgozzG7CgidZTeohsrb6ayuLP803tRfmPNZ7cUXwdHI= -github.com/FyshOS/saver v0.1.1-0.20260402091637-a576dfc113a3 h1:O+cisIWukjMLG3jMNdAN4jOgZfCIXgsrjfTWFzeWL1k= -github.com/FyshOS/saver v0.1.1-0.20260402091637-a576dfc113a3/go.mod h1:WvBivsR68hbiahFFjEf5Yzv1chBd/Slo8TRziPgzEOY= +github.com/FyshOS/saver v0.1.1-0.20260407200543-762135717028 h1:gnZOxK+y64+zaAreh9PyispXXCSz9vV5WgM+qO5h7ok= +github.com/FyshOS/saver v0.1.1-0.20260407200543-762135717028/go.mod h1:WvBivsR68hbiahFFjEf5Yzv1chBd/Slo8TRziPgzEOY= github.com/Knetic/govaluate v3.0.0+incompatible h1:7o6+MAPhYTCF0+fdvoz1xDedhRb4f6s9Tn1Tt7/WTEg= github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8= diff --git a/internal/ui/background.go b/internal/ui/background.go index 9c9e11f19..684ab631c 100644 --- a/internal/ui/background.go +++ b/internal/ui/background.go @@ -14,7 +14,6 @@ import ( type background struct { widget.BaseWidget - compositor *CompositorWidget } func (b *background) CreateRenderer() fyne.WidgetRenderer { @@ -23,7 +22,7 @@ func (b *background) CreateRenderer() fyne.WidgetRenderer { } func (b *background) loadModules() []fyne.CanvasObject { - objects := b.screenWallpapers() + objects := []fyne.CanvasObject{container.NewStack(loadWallpaper())} // Add screen area modules (e.g. desktop files) for _, m := range fynedesk.Instance().Modules() { @@ -34,63 +33,9 @@ func (b *background) loadModules() []fyne.CanvasObject { } } - // Compositor on top so windows are drawn over desktop content - if b.compositor != nil { - objects = append(objects, b.compositor) - } - return objects } -// screenWallpapers creates one wallpaper image per screen, positioned correctly -// within the multi-screen window, mirroring the X root background layout. -func (b *background) screenWallpapers() []fyne.CanvasObject { - inst := fynedesk.Instance() - if inst == nil { - return nil - } - - screens := inst.Screens() - if screens == nil { - return nil - } - - // Find the window origin (top-left of all screens bounding box) - originX, originY := 0, 0 - for _, screen := range screens.Screens() { - if screen.X < originX { - originX = screen.X - } - if screen.Y < originY { - originY = screen.Y - } - } - - scale := screens.Primary().CanvasScale() - wallpapers := container.NewWithoutLayout() - - for _, screen := range screens.Screens() { - img := loadWallpaper() - img.Move(fyne.NewPos( - float32(screen.X-originX)/scale, - float32(screen.Y-originY)/scale, - )) - img.Resize(fyne.NewSize( - float32(screen.Width)/scale, - float32(screen.Height)/scale, - )) - wallpapers.Add(img) - } - - // In embedded mode the window can be resized, so wrap wallpapers in a - // stack so they stretch to fill the background rather than keeping - // the initial hardcoded size. - if _, ok := screens.(*embeddedScreensProvider); ok { - return []fyne.CanvasObject{container.NewStack(wallpapers.Objects...)} - } - return []fyne.CanvasObject{wallpapers} -} - func (b *background) updateBackground(_ string) { b.Refresh() } @@ -115,8 +60,8 @@ func loadWallpaper() fyne.CanvasObject { return src.Load(set.Theme(), set.ThemeVariant()) } -func newBackground(compositor *CompositorWidget) *background { - ret := &background{compositor: compositor} +func newBackground() *background { + ret := &background{} ret.ExtendBaseWidget(ret) return ret } diff --git a/internal/ui/compositor.go b/internal/ui/compositor.go index 76d6e4894..ca2f69bee 100644 --- a/internal/ui/compositor.go +++ b/internal/ui/compositor.go @@ -26,13 +26,14 @@ type WindowImage struct { type CompositorWidget struct { widget.BaseWidget + Screen *fynedesk.Screen // the screen this widget renders on mu sync.RWMutex images []*WindowImage // Fyne draw order: first = bottom, last = top } -// NewCompositorWidget creates a new compositor widget. -func NewCompositorWidget() *CompositorWidget { - w := &CompositorWidget{} +// NewCompositorWidget creates a new compositor widget for the given screen. +func NewCompositorWidget(screen *fynedesk.Screen) *CompositorWidget { + w := &CompositorWidget{Screen: screen} w.ExtendBaseWidget(w) return w } @@ -137,7 +138,10 @@ func (r *compositorRenderer) Refresh() { r.widget.mu.RLock() defer r.widget.mu.RUnlock() - scale := screenScale() + scale := float32(1) + if r.widget.Screen != nil { + scale = r.widget.Screen.CanvasScale() + } objs := make([]fyne.CanvasObject, len(r.widget.images)) for i, wi := range r.widget.images { @@ -148,12 +152,5 @@ func (r *compositorRenderer) Refresh() { // Update the stable container's objects in place — never replace the container itself. r.cont.Objects = objs -} - -func screenScale() float32 { - inst := fynedesk.Instance() - if inst == nil { - return 1 - } - return inst.Screens().Primary().CanvasScale() + r.cont.Refresh() } diff --git a/internal/ui/desk.go b/internal/ui/desk.go index 0c31ba938..62daf9404 100644 --- a/internal/ui/desk.go +++ b/internal/ui/desk.go @@ -25,6 +25,24 @@ const ( RootWindowName = "Fyne Desktop" ) +// screenWindow holds the Fyne window and per-screen widgets for a single monitor. +type screenWindow struct { + screen *fynedesk.Screen + win fyne.Window + compositor *CompositorWidget + compositorOverlay *CompositorWidget + bg *background + overlay *fyne.Container +} + +// ScreenCompositors groups the compositor widgets for a single screen, +// passed to the platform compositor so it can route windows per-monitor. +type ScreenCompositors struct { + Screen *fynedesk.Screen + Normal *CompositorWidget + Overlay *CompositorWidget +} + type desktop struct { wm.ShortcutHandler app fyne.App @@ -38,17 +56,15 @@ type desktop struct { showMenu func(*fyne.Menu, fyne.Position) moduleCache []fynedesk.Module - bar *bar - widgets *widgetPanel - mouse fyne.CanvasObject - overlay *fyne.Container - root fyne.Window - desk int - deskAnim *fyne.Animation - deskAnimTargets map[fynedesk.Window]fyne.Position // where the in-flight animation is heading - compositor *CompositorWidget - compositorOverlay *CompositorWidget - compositorDone chan struct{} + bar *bar + widgets *widgetPanel + mouse fyne.CanvasObject + screenWindows []*screenWindow + primaryWin *screenWindow + desk int + deskAnim *fyne.Animation + deskAnimTargets map[fynedesk.Window]fyne.Position // where the in-flight animation is heading + compositorDone chan struct{} } func (l *desktop) Desktop() int { @@ -138,50 +154,34 @@ func (l *desktop) Layout(objects []fyne.CanvasObject, size fyne.Size) { esp.UpdatePrimarySize(int(size.Width), int(size.Height)) } - // Calculate the window origin (top-left of the bounding box of all screens) - originX, originY := 0, 0 - for _, screen := range l.screens.Screens() { - if screen.X < originX { - originX = screen.X - } - if screen.Y < originY { - originY = screen.Y + // Each window covers exactly one screen, so origin is always 0,0. + pW := size.Width + pH := size.Height + + // objects order: background, [compositor], bar, widgets, [compositorOverlay], overlay, mouse + // Size all full-window layers (everything except bar and widgets) to fill. + for _, o := range objects { + if o == l.bar || o == l.widgets || o == l.mouse { + continue } + o.Resize(size) + o.Move(fyne.NewPos(0, 0)) } - // Position background, bar and widgets on the primary screen only. - // Coordinates are relative to the window origin. - primary := l.screens.Primary() - scale := primary.CanvasScale() - - pX := float32(primary.X-originX) / scale - pY := float32(primary.Y-originY) / scale - pW := float32(primary.Width) / scale - pH := float32(primary.Height) / scale - - bg := objects[0].(*background) - bg.Resize(size) - if l.Settings().NarrowLeftLauncher() { l.bar.Resize(fyne.NewSize(wmtheme.NarrowBarWidth, pH)) - l.bar.Move(fyne.NewPos(pX, pY)) + l.bar.Move(fyne.NewPos(0, 0)) } else { barHeight := l.bar.MinSize().Height l.bar.Resize(fyne.NewSize(pW, barHeight+1)) // add 1 so rounding cannot trigger mouse out on bottom edge - l.bar.Move(fyne.NewPos(pX, pY+pH-barHeight)) + l.bar.Move(fyne.NewPos(0, pH-barHeight)) } l.bar.Refresh() widgetsWidth := l.widgets.MinSize().Width l.widgets.Resize(fyne.NewSize(widgetsWidth, pH)) - l.widgets.Move(fyne.NewPos(pX+pW-widgetsWidth, pY)) + l.widgets.Move(fyne.NewPos(pW-widgetsWidth, 0)) l.widgets.Refresh() - - // Size overlay objects (between widgets and mouse) to the full window - for i := 3; i < len(objects)-1; i++ { - objects[i].Resize(size) - objects[i].Move(fyne.NewPos(0, 0)) - } } func (l *desktop) MinSize(_ []fyne.CanvasObject) fyne.Size { @@ -189,7 +189,10 @@ func (l *desktop) MinSize(_ []fyne.CanvasObject) fyne.Size { } func (l *desktop) Root() fyne.Window { - return l.root + if l.primaryWin == nil { + return nil + } + return l.primaryWin.win } func (l *desktop) ShowMenuAt(menu *fyne.Menu, pos fyne.Position) { @@ -287,7 +290,7 @@ func (l *desktop) showOverlayWithBackdrop(content fyne.CanvasObject, size fyne.S combined = container.NewStack(bg, container.NewWithoutLayout(catch)) // Size the combined to fill the full window - winSize := l.root.Canvas().Size() + winSize := l.primaryWin.win.Canvas().Size() l.showOverlay(combined, winSize, fyne.NewPos(0, 0), focus) return combined } @@ -300,18 +303,20 @@ func (l *desktop) ShowOverlay(content fyne.CanvasObject, size fyne.Size, pos fyn } func (l *desktop) showOverlay(content fyne.CanvasObject, size fyne.Size, pos fyne.Position, focus fyne.Focusable) { + overlay := l.primaryWin.overlay + win := l.primaryWin.win fyne.Do(func() { content.Resize(size) content.Move(pos) - l.overlay.Add(content) - l.overlay.Refresh() + overlay.Add(content) + overlay.Refresh() if is, ok := l.wm.(inputShaper); ok { is.SetOverlayActive(true) } if focus != nil { - l.root.Canvas().Focus(focus) + win.Canvas().Focus(focus) } }) } @@ -319,11 +324,12 @@ func (l *desktop) showOverlay(content fyne.CanvasObject, size fyne.Size, pos fyn // HideOverlay removes content from the desktop overlay layer. // When no overlays remain, input shapes are restored to normal. func (l *desktop) HideOverlay(content fyne.CanvasObject) { + overlay := l.primaryWin.overlay fyne.Do(func() { - l.overlay.Remove(content) - l.overlay.Refresh() + overlay.Remove(content) + overlay.Refresh() - if len(l.overlay.Objects) == 0 { + if len(overlay.Objects) == 0 { if is, ok := l.wm.(inputShaper); ok { is.SetOverlayActive(false) } @@ -332,51 +338,141 @@ func (l *desktop) HideOverlay(content fyne.CanvasObject) { } func (l *desktop) updateBackgrounds(path string) { - root := l.root.Content().(*fyne.Container).Objects[0] - if back, ok := root.(*background); ok { - back.updateBackground(path) - } else { // embed mode has another container - root.(*fyne.Container).Objects[0].(*background).updateBackground(path) + for _, sw := range l.screenWindows { + if sw.bg != nil { + sw.bg.updateBackground(path) + } } } -func (l *desktop) createPrimaryContent() fyne.CanvasObject { +func (l *desktop) createPrimaryContent(sw *screenWindow) fyne.CanvasObject { l.bar = newBar(l) l.widgets = newWidgetPanel(l) l.mouse = newMouse() l.mouse.Hide() - // Order: background (wallpapers + files + compositor) -> bar -> widgets -> compositor overlay -> UI overlay -> mouse - objects := []fyne.CanvasObject{newBackground(l.compositor), l.bar, l.widgets} + sw.bg = newBackground() + + // Order: background -> compositor -> bar -> widgets -> compositor overlay -> UI overlay -> mouse + objects := []fyne.CanvasObject{sw.bg} + + // Normal compositor for regular windows below desktop chrome + if sw.compositor != nil { + objects = append(objects, sw.compositor) + } + + objects = append(objects, l.bar, l.widgets) // Compositor overlay for fullscreen windows above desktop chrome - if l.compositorOverlay != nil { - objects = append(objects, l.compositorOverlay) + if sw.compositorOverlay != nil { + objects = append(objects, sw.compositorOverlay) } // UI overlay for menus, dialogs, switcher, notifications - l.overlay = container.NewWithoutLayout() - objects = append(objects, l.overlay, l.mouse) + sw.overlay = container.NewWithoutLayout() + objects = append(objects, sw.overlay, l.mouse) return container.New(l, objects...) } -func (l *desktop) createRoot(screens fynedesk.ScreenList) fyne.Window { - win := l.newDesktopWindowFull() +// secondaryLayout is a simple layout for non-primary screen windows (no bar/widgets). +type secondaryLayout struct{} + +func (s *secondaryLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + for _, o := range objects { + o.Resize(size) + o.Move(fyne.NewPos(0, 0)) + } +} + +func (s *secondaryLayout) MinSize(_ []fyne.CanvasObject) fyne.Size { + return fyne.NewSize(640, 480) +} + +func (l *desktop) createSecondaryContent(sw *screenWindow) fyne.CanvasObject { + sw.bg = newBackground() + + objects := []fyne.CanvasObject{sw.bg} + + // Normal compositor for regular windows + if sw.compositor != nil { + objects = append(objects, sw.compositor) + } - win.SetContent(l.createPrimaryContent()) + // Compositor overlay for fullscreen windows + if sw.compositorOverlay != nil { + objects = append(objects, sw.compositorOverlay) + } - return win + sw.overlay = container.NewWithoutLayout() + objects = append(objects, sw.overlay) + return container.New(&secondaryLayout{}, objects...) } func (l *desktop) setupRoot() { - if l.root == nil { - l.root = l.createRoot(l.screens) + primary := l.screens.Primary() + + // Build or update screenWindow for each screen + existingByName := make(map[string]*screenWindow, len(l.screenWindows)) + for _, sw := range l.screenWindows { + existingByName[sw.screen.Name] = sw } - // Size the root window to cover all screens so the compositor can render windows on any display - w, h := l.RootSizePixels() - scale := l.screens.Primary().CanvasScale() - l.root.Resize(fyne.NewSize(float32(w)/scale, float32(h)/scale)) + var newWindows []*screenWindow + for _, screen := range l.screens.Screens() { + sw := existingByName[screen.Name] + if sw != nil { + // Update screen pointer (geometry may have changed) + sw.screen = screen + if sw.compositor != nil { + sw.compositor.Screen = screen + } + if sw.compositorOverlay != nil { + sw.compositorOverlay.Screen = screen + } + delete(existingByName, screen.Name) + } else { + // Create new screenWindow + sw = &screenWindow{screen: screen} + if l.compositorDone != nil { + sw.compositor = NewCompositorWidget(screen) + sw.compositorOverlay = NewCompositorWidget(screen) + } + win := l.app.NewWindow(RootWindowName + screen.Name) + win.SetPadded(false) + sw.win = win + + if screen == primary { + win.SetMaster() + win.SetOnClosed(func() { + if l.compositorDone != nil { + close(l.compositorDone) + } + l.wm.Close() + }) + win.SetContent(l.createPrimaryContent(sw)) + } else { + win.SetContent(l.createSecondaryContent(sw)) + } + } + + newWindows = append(newWindows, sw) + if screen == primary { + l.primaryWin = sw + } + } + + // Close windows for disconnected screens + for _, sw := range existingByName { + sw.win.Close() + } + + l.screenWindows = newWindows + + // Resize each window to cover its screen + for _, sw := range l.screenWindows { + scale := sw.screen.CanvasScale() + sw.win.Resize(fyne.NewSize(float32(sw.screen.Width)/scale, float32(sw.screen.Height)/scale)) + } } func (l *desktop) RecentApps() []appie.AppData { @@ -610,12 +706,9 @@ func (l *desktop) Screens() fynedesk.ScreenList { return l.screens } -// NewDesktop creates a new desktop in fullscreen for main usage. -// The WindowManager passed in will be used to manage the screen it is loaded on. -// An ApplicationProvider is used to lookup application icons from the operating system. // CompositorRunFunc is a function that runs a platform compositor using the -// provided widgets. It blocks until done is closed. -type CompositorRunFunc func(done chan struct{}, normal, overlay *CompositorWidget) error +// provided per-screen widgets. It blocks until done is closed. +type CompositorRunFunc func(done chan struct{}, screens []ScreenCompositors) error // NewDesktop creates the full desktop environment with window management. // If compositorRun is non-nil, the compositor is started in a background goroutine. @@ -623,8 +716,6 @@ func NewDesktop(app fyne.App, mgr fynedesk.WindowManager, icons appie.Provider, desk := newDesktop(app, mgr, icons) desk.run = desk.runFull if compositorRun != nil { - desk.compositor = NewCompositorWidget() - desk.compositorOverlay = NewCompositorWidget() desk.compositorDone = make(chan struct{}) } screenProvider.AddChangeListener(desk.setupRoot) @@ -634,7 +725,8 @@ func NewDesktop(app fyne.App, mgr fynedesk.WindowManager, icons appie.Provider, if compositorRun != nil { go func() { - if err := compositorRun(desk.compositorDone, desk.compositor, desk.compositorOverlay); err != nil { + screens := desk.screenCompositors() + if err := compositorRun(desk.compositorDone, screens); err != nil { fyne.LogError("Compositor failed", err) } }() @@ -647,6 +739,21 @@ func NewDesktop(app fyne.App, mgr fynedesk.WindowManager, icons appie.Provider, return desk } +// screenCompositors returns the per-screen compositor widget pairs. +func (l *desktop) screenCompositors() []ScreenCompositors { + var out []ScreenCompositors + for _, sw := range l.screenWindows { + if sw.compositor != nil { + out = append(out, ScreenCompositors{ + Screen: sw.screen, + Normal: sw.compositor, + Overlay: sw.compositorOverlay, + }) + } + } + return out +} + // NewEmbeddedDesktop creates a new windowed desktop for test purposes. // An ApplicationProvider is used to lookup application icons from the operating system. // If run during CI for testing it will return an in-memory window using the @@ -657,9 +764,15 @@ func NewEmbeddedDesktop(app fyne.App, icons appie.Provider) fynedesk.Desktop { desk.run = desk.runEmbed desk.showMenu = desk.showMenuEmbed - desk.root = desk.newDesktopWindowEmbed() - over := wm.setWindow(desk.root) - desk.root.SetContent(container.NewStack(desk.createPrimaryContent(), over)) + win := desk.newDesktopWindowEmbed() + sw := &screenWindow{ + screen: desk.screens.Primary(), + win: win, + } + desk.screenWindows = []*screenWindow{sw} + desk.primaryWin = sw + over := wm.setWindow(win) + win.SetContent(container.NewStack(desk.createPrimaryContent(sw), over)) return desk } diff --git a/internal/ui/desk_embed.go b/internal/ui/desk_embed.go index 64cab6e66..69f7a2587 100644 --- a/internal/ui/desk_embed.go +++ b/internal/ui/desk_embed.go @@ -15,11 +15,11 @@ func (l *desktop) newDesktopWindowEmbed() fyne.Window { } func (l *desktop) runEmbed() { - l.root.ShowAndRun() + l.primaryWin.win.ShowAndRun() } func (l *desktop) showMenuEmbed(menu *fyne.Menu, pos fyne.Position) { - wid := widget.NewPopUpMenu(menu, l.root.Canvas()) + wid := widget.NewPopUpMenu(menu, l.primaryWin.win.Canvas()) wid.Resize(fyne.NewSize(wmtheme.WidgetPanelWidth, wid.MinSize().Height)) wid.ShowAtPosition(pos) } diff --git a/internal/ui/desk_full.go b/internal/ui/desk_full.go index faa720f30..e21c27fa6 100644 --- a/internal/ui/desk_full.go +++ b/internal/ui/desk_full.go @@ -13,21 +13,6 @@ import ( wmtheme "fyshos.com/fynedesk/theme" ) -func (l *desktop) newDesktopWindowFull() fyne.Window { - desk := l.app.NewWindow(RootWindowName) - desk.SetPadded(false) - - desk.SetMaster() - desk.SetOnClosed(func() { - if l.compositorDone != nil { - close(l.compositorDone) - } - l.wm.Close() - }) - - return desk -} - func (l *desktop) runFull() { debug.SetPanicOnFault(true) @@ -39,7 +24,14 @@ func (l *desktop) runFull() { } }() - l.root.ShowAndRun() + // Show secondary windows before starting the event loop on primary + for _, sw := range l.screenWindows { + if sw != l.primaryWin { + sw.win.Show() + } + } + + l.primaryWin.win.ShowAndRun() } func (l *desktop) showMenuFull(menu *fyne.Menu, pos fyne.Position) { @@ -73,7 +65,7 @@ func (l *desktop) showMenuFull(menu *fyne.Menu, pos fyne.Position) { // Check if submenus would overflow the right edge of the canvas — if so, // the catch area extends to the left and the menu content is offset within it. // This mirrors Fyne's own submenu flip logic which checks against the full canvas width. - canvasWidth := l.root.Canvas().Size().Width + canvasWidth := l.primaryWin.win.Canvas().Size().Width contentOffset := fyne.NewPos(0, 0) if childWidth > 0 && pos.X+size.Width+childWidth > canvasWidth { pos.X -= childWidth diff --git a/internal/ui/launcher.go b/internal/ui/launcher.go index f0c45458d..68b27dc41 100644 --- a/internal/ui/launcher.go +++ b/internal/ui/launcher.go @@ -241,9 +241,8 @@ func (l *picker) show() { d := l.desk.(*desktop) primary := d.Screens().Primary() scale := primary.CanvasScale() - originX, originY := screenOrigin(d.Screens()) - midX := float32(primary.X-originX+primary.Width/2) / scale - midY := float32(primary.Y-originY+primary.Height/2) / scale + midX := float32(primary.Width/2) / scale + midY := float32(primary.Height/2) / scale entryHeight := l.entry.MinSize().Height l.fullSize = fyne.NewSize(launcherWidth, diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 9d15c79c5..2f9827c45 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -17,7 +17,6 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" - "fyshos.com/fynedesk" wmtheme "fyshos.com/fynedesk/theme" ) @@ -102,11 +101,8 @@ func (w *widgetPanel) askLogout() { scale := primary.CanvasScale() pW := float32(primary.Width) / scale pH := float32(primary.Height) / scale - originX, originY := screenOrigin(w.desk.Screens()) - pX := float32(primary.X-originX) / scale - pY := float32(primary.Y-originY) / scale size := fyne.NewSize(280, 150) - pos := fyne.NewPos(pX+(pW-size.Width)/2, pY+(pH-size.Height)/2) + pos := fyne.NewPos((pW-size.Width)/2, (pH-size.Height)/2) combined = w.desk.(*desktop).ShowOverlayWithBackdrop(logoutContent, size, size, pos, fyne.Position{}) } @@ -170,9 +166,8 @@ func (w *widgetPanel) showAccountMenu(_ fyne.CanvasObject) { primary := w.desk.Screens().Primary() scale := primary.CanvasScale() - originX, originY := screenOrigin(w.desk.Screens()) - pRight := float32(primary.X-originX+primary.Width) / scale - pBottom := float32(primary.Y-originY+primary.Height) / scale + pRight := float32(primary.Width) / scale + pBottom := float32(primary.Height) / scale pos := fyne.NewPos(pRight-300, pBottom-360) menuSize := fyne.NewSize(300, 360) combined = w.desk.(*desktop).ShowOverlayWithBackdrop(menuContent, menuSize, menuSize, pos, fyne.Position{}) @@ -194,16 +189,3 @@ func (w *widgetPanel) loadIcon(app appie.AppData, btn *widget.Button) { btn.SetIcon(iconRes) }) } - -func screenOrigin(screens fynedesk.ScreenList) (int, int) { - originX, originY := 0, 0 - for _, screen := range screens.Screens() { - if screen.X < originX { - originX = screen.X - } - if screen.Y < originY { - originY = screen.Y - } - } - return originX, originY -} diff --git a/internal/ui/notifications.go b/internal/ui/notifications.go index 8d2bb520a..1ec78303d 100644 --- a/internal/ui/notifications.go +++ b/internal/ui/notifications.go @@ -59,9 +59,8 @@ func (n *notification) show(list *fyne.Container) { inst := fynedesk.Instance() primary := inst.Screens().Primary() scale := primary.CanvasScale() - originX, originY := screenOrigin(inst.Screens()) - pRight := float32(primary.X-originX+primary.Width) / scale - pos := fyne.NewPos(pRight-width-offset-wmtheme.NarrowBarWidth, float32(primary.Y-originY)/scale+offset) + pRight := float32(primary.Width) / scale + pos := fyne.NewPos(pRight-width-offset-wmtheme.NarrowBarWidth, offset) inst.ShowOverlay(n.overlay, fyne.NewSize(width, height), pos) } else { fyne.Do(func() { diff --git a/internal/ui/switcher.go b/internal/ui/switcher.go index 35d10ad22..42ebde408 100644 --- a/internal/ui/switcher.go +++ b/internal/ui/switcher.go @@ -244,12 +244,9 @@ func (s *Switcher) Show() { primary := inst.Screens().Primary() scale := primary.CanvasScale() - originX, originY := screenOrigin(inst.Screens()) - pX := float32(primary.X-originX) / scale - pY := float32(primary.Y-originY) / scale pW := float32(primary.Width) / scale pH := float32(primary.Height) / scale - pos := fyne.NewPos(pX+(pW-size.Width)/2, pY+(pH-size.Height)/2) + pos := fyne.NewPos((pW-size.Width)/2, (pH-size.Height)/2) inst.ShowOverlay(s.content, size, pos) } diff --git a/internal/x11/composit/gocompositor.go b/internal/x11/composit/gocompositor.go index 814baa38b..0eea485f8 100644 --- a/internal/x11/composit/gocompositor.go +++ b/internal/x11/composit/gocompositor.go @@ -40,6 +40,7 @@ type client struct { opaqueType opaqueType damaged bool skipped bool // Fyne Desktop window or other skipped windows + fullscreened bool // unredirected for fullscreen bypass visualMoving bool // position being managed by VisualMoveCallback (drag/animation) geom xproto.GetGeometryReply @@ -142,12 +143,12 @@ func setup(conn *xgb.Conn) error { } // Run starts the X11 compositor event loop. It captures window content and -// renders it into the provided widgets. The normal widget shows regular windows; +// renders it into per-screen widgets. The normal widget shows regular windows; // the overlay widget shows fullscreen windows above desktop chrome. // Run blocks until done is closed. // //gocyclo:ignore -func Run(done chan struct{}, w *ui.CompositorWidget, overlay *ui.CompositorWidget) error { +func Run(done chan struct{}, screenComps []ui.ScreenCompositors) error { c, err := xgbutil.NewConn() if err != nil { return err @@ -156,11 +157,18 @@ func Run(done chan struct{}, w *ui.CompositorWidget, overlay *ui.CompositorWidge conn := c.Conn() defer conn.Close() - ws := &widgets{normal: w, overlay: overlay} + ws := &widgets{} + for _, sc := range screenComps { + ws.screens = append(ws.screens, screenWidgets{ + screen: sc.Screen, + normal: sc.Normal, + overlay: sc.Overlay, + }) + } // Set up visual move callback for fast drag repositioning. // Called from the main thread (fyne.Do context) so no additional queueing needed. - x11.VisualMoveCallback = func(winID uint32, x, y int16, width, height uint16) { + x11.VisualMoveCallback = func(winID uint32, absX, absY int16, width, height uint16) { win := xproto.Window(winID) // Mark the client as visually moving so refreshWindows skips recapture @@ -168,19 +176,53 @@ func Run(done chan struct{}, w *ui.CompositorWidget, overlay *ui.CompositorWidge c.visualMoving = true } - for _, target := range []*ui.CompositorWidget{ws.normal, ws.overlay} { - wi := target.GetWindow(winID) - if wi == nil { - continue + // Route the window to all screens it overlaps, remove from others + for i := range ws.screens { + sw := &ws.screens[i] + if intersectsScreen(absX, absY, width, height, sw.screen) { + // Find or create the window image in this screen's widget + for _, target := range []*ui.CompositorWidget{sw.normal, sw.overlay} { + wi := target.GetWindow(winID) + if wi == nil { + // Check the other widget on this screen + continue + } + localX := absX - int16(sw.screen.X) + localY := absY - int16(sw.screen.Y) + wi.X = localX + wi.Y = localY + wi.W = width + wi.H = height + scale := sw.screen.CanvasScale() + wi.Img.Move(fyne.NewPos(float32(localX)/scale, float32(localY)/scale)) + } + // If not in any widget on this screen yet, ensure it + if sw.normal.GetWindow(winID) == nil && sw.overlay.GetWindow(winID) == nil { + wi := sw.normal.EnsureWindow(winID) + // Copy image from another screen that has it + copyImageFromOtherScreen(ws, winID, wi, sw) + localX := absX - int16(sw.screen.X) + localY := absY - int16(sw.screen.Y) + wi.X = localX + wi.Y = localY + wi.W = width + wi.H = height + scale := sw.screen.CanvasScale() + wi.Img.Move(fyne.NewPos(float32(localX)/scale, float32(localY)/scale)) + wi.Img.Resize(fyne.NewSize(float32(width)/scale, float32(height)/scale)) + sw.normal.Refresh() + } + } else { + // Remove from this screen if present + if sw.normal.GetWindow(winID) != nil { + sw.normal.RemoveWindow(winID) + sw.normal.Refresh() + } + if sw.overlay.GetWindow(winID) != nil { + sw.overlay.RemoveWindow(winID) + sw.overlay.Refresh() + } } - wi.X = x - wi.Y = y - wi.W = width - wi.H = height - - scale := screenScale() - wi.Img.Move(fyne.NewPos(float32(x)/scale, float32(y)/scale)) - return } } @@ -218,10 +260,14 @@ func Run(done chan struct{}, w *ui.CompositorWidget, overlay *ui.CompositorWidge if c.skipped || c.attributes.MapState != xproto.MapStateViewable { continue } - ws.targetFor(c).EnsureWindow(uint32(c.win)) + if isFullscreenClient(c) { + updateFullscreen(conn, ws, c) + } else { + ensureWindowOnScreens(ws, c) + } } syncOrder(ws) - ws.refreshBoth() + ws.refreshAll() // Initial capture of all visible windows refreshWindows(conn, ws) @@ -293,7 +339,7 @@ func Run(done chan struct{}, w *ui.CompositorWidget, overlay *ui.CompositorWidge } if e.Atom == netWmStateAtom { if cl := getClientFromWindow(e.Window); cl != nil { - updateFullscreen(ws, cl) + updateFullscreen(conn, ws, cl) } } case damage.NotifyEvent: @@ -353,28 +399,86 @@ func setupRoot(conn *xgb.Conn) error { return nil } -// widgets pairs the normal and overlay compositor widgets. -type widgets struct { +// screenWidgets holds the compositor widgets for a single screen. +type screenWidgets struct { + screen *fynedesk.Screen normal *ui.CompositorWidget overlay *ui.CompositorWidget } -// targetFor returns the appropriate widget for a client based on fullscreen state. -func (ws *widgets) targetFor(c *client) *ui.CompositorWidget { - if checkFullscreen(c) { - return ws.overlay +// widgets holds per-screen compositor widget pairs. +type widgets struct { + screens []screenWidgets +} + +// targetFor returns the appropriate widget type name for a client based on fullscreen state. +func isFullscreenClient(c *client) bool { + return checkFullscreen(c) +} + +// screensForClient returns the screen widgets whose screens overlap the client's geometry. +func (ws *widgets) screensForClient(c *client) []*screenWidgets { + var result []*screenWidgets + for i := range ws.screens { + if intersectsScreen(c.geom.X, c.geom.Y, + c.geom.Width+c.geom.BorderWidth*2, c.geom.Height+c.geom.BorderWidth*2, + ws.screens[i].screen) { + result = append(result, &ws.screens[i]) + } + } + if len(result) == 0 && len(ws.screens) > 0 { + // Fallback: assign to nearest screen (use ScreenForGeometry) + inst := fynedesk.Instance() + if inst != nil { + s := inst.Screens().ScreenForGeometry(int(c.geom.X), int(c.geom.Y), + int(c.geom.Width), int(c.geom.Height)) + for i := range ws.screens { + if ws.screens[i].screen == s { + result = append(result, &ws.screens[i]) + break + } + } + } } - return ws.normal + return result } -// refreshBoth refreshes both widgets via fyne.Do. -func (ws *widgets) refreshBoth() { +// refreshAll refreshes all screen widgets via fyne.Do. +func (ws *widgets) refreshAll() { fyne.Do(func() { - ws.normal.Refresh() - ws.overlay.Refresh() + for i := range ws.screens { + ws.screens[i].normal.Refresh() + ws.screens[i].overlay.Refresh() + } }) } +// intersectsScreen returns whether a rectangle overlaps a screen. +func intersectsScreen(x, y int16, w, h uint16, screen *fynedesk.Screen) bool { + return int(x) < screen.X+screen.Width && + int(x)+int(w) > screen.X && + int(y) < screen.Y+screen.Height && + int(y)+int(h) > screen.Y +} + +// copyImageFromOtherScreen copies the canvas.Image data from another screen's +// widget entry for the same window ID into the target WindowImage. +func copyImageFromOtherScreen(ws *widgets, winID uint32, target *ui.WindowImage, exclude *screenWidgets) { + for i := range ws.screens { + sw := &ws.screens[i] + if sw == exclude { + continue + } + for _, w := range []*ui.CompositorWidget{sw.normal, sw.overlay} { + if src := w.GetWindow(winID); src != nil && src.Img.Image != nil { + target.Img.Image = src.Img.Image + target.Img.Translucency = src.Img.Translucency + return + } + } + } +} + // wmWindow returns the WM's Window for a compositor client, or nil. func wmWindow(c *client) fynedesk.Window { inst := fynedesk.Instance() @@ -397,63 +501,91 @@ func checkFullscreen(c *client) bool { } // updateFullscreen checks whether a client's fullscreen state changed and -// moves it between the normal and overlay widgets if needed. -func updateFullscreen(ws *widgets, c *client) { - target := ws.targetFor(c) - winID := uint32(c.win) - - // Already in the correct widget — nothing to do. - if target.GetWindow(winID) != nil { - return +// unredirects/redirects the window so that fullscreen windows bypass compositing. +func updateFullscreen(conn *xgb.Conn, ws *widgets, c *client) { + if c.skipped { + return // root window or screensaver — don't touch } - // Move from the other widget to the correct one. - var from *ui.CompositorWidget - if target == ws.overlay { - from = ws.normal - } else { - from = ws.overlay + winID := uint32(c.win) + isFS := isFullscreenClient(c) + + if isFS && !c.fullscreened { + // Unredirect: let X11 display the window directly, bypassing compositing. + removeWindowFromAllScreens(ws, winID) + freeClientPixmap(conn, c) + if c.damage != 0 { + _ = damage.Destroy(conn, c.damage) + c.damage = 0 + } + _ = composite.UnredirectWindowChecked(conn, c.win, composite.RedirectManual).Check() + c.fullscreened = true + } else if !isFS && c.fullscreened { + // Re-redirect: bring the window back under compositing. + _ = composite.RedirectWindowChecked(conn, c.win, composite.RedirectManual).Check() + c.fullscreened = false + if c.damage == 0 { + dmg, err := damage.NewDamageId(conn) + if err == nil { + if err = damage.CreateChecked(conn, dmg, xproto.Drawable(c.win), damage.ReportLevelNonEmpty).Check(); err == nil { + c.damage = dmg + } + } + } + ensureWindowOnScreens(ws, c) + c.damaged = true + allDamage = true + syncOrder(ws) + ws.refreshAll() } - - from.RemoveWindow(winID) - target.EnsureWindow(winID) - c.damaged = true - allDamage = true - syncOrder(ws) - ws.refreshBoth() } -// syncOrder rebuilds the image ordering in both widgets to match the clients list. +// syncOrder rebuilds the image ordering in all screen widgets to match the clients list. func syncOrder(ws *widgets) { order := make([]uint32, len(clients)) for i, c := range clients { order[i] = uint32(c.win) } - ws.normal.Reorder(order) - ws.overlay.Reorder(order) + for i := range ws.screens { + ws.screens[i].normal.Reorder(order) + ws.screens[i].overlay.Reorder(order) + } } -func screenScale() float32 { - inst := fynedesk.Instance() - if inst == nil { - return 1 +// ensureWindowOnScreens adds a window to all screen widgets that overlap its geometry. +func ensureWindowOnScreens(ws *widgets, c *client) { + winID := uint32(c.win) + isFS := isFullscreenClient(c) + for _, sw := range ws.screensForClient(c) { + if isFS { + sw.overlay.EnsureWindow(winID) + } else { + sw.normal.EnsureWindow(winID) + } + } +} + +// removeWindowFromAllScreens removes a window from all screen widgets. +func removeWindowFromAllScreens(ws *widgets, winID uint32) { + for i := range ws.screens { + ws.screens[i].normal.RemoveWindow(winID) + ws.screens[i].overlay.RemoveWindow(winID) } - return inst.Screens().Primary().CanvasScale() } // refreshWindows captures all damaged windows and updates the Fyne widgets. func refreshWindows(conn *xgb.Conn, ws *widgets) { type captured struct { - wi *ui.WindowImage + c *client img *image.NRGBA translucency float64 - x, y int16 - w, h uint16 + totalW uint16 + totalH uint16 } - var updates []captured + var caps []captured for _, c := range clients { - if !c.damaged || c.skipped || c.visualMoving { + if !c.damaged || c.skipped || c.fullscreened || c.visualMoving { continue } if c.attributes.MapState != xproto.MapStateViewable { @@ -485,47 +617,63 @@ func refreshWindows(conn *xgb.Conn, ws *widgets) { } w := wmWindow(c) if w == nil || (!w.Fullscreened() && !w.Maximized()) { - roundCorners(img, int(5*screenScale())) - } - - target := ws.targetFor(c) - wi := target.GetWindow(uint32(c.win)) - if wi == nil { - continue + // Use primary screen scale for corner rounding as a reasonable default + scale := float32(1) + if len(ws.screens) > 0 { + scale = ws.screens[0].screen.CanvasScale() + } + roundCorners(img, int(5*scale)) } - updates = append(updates, captured{ - wi: wi, + caps = append(caps, captured{ + c: c, img: img, translucency: computeTranslucency(conn, c), - x: c.geom.X, - y: c.geom.Y, - w: totalW, - h: totalH, + totalW: totalW, + totalH: totalH, }) } - if len(updates) == 0 { + if len(caps) == 0 { return } - scale := screenScale() fyne.Do(func() { - for _, u := range updates { - u.wi.Img.Image = u.img - u.wi.Img.Translucency = u.translucency - // Set position/size if not yet initialized (first capture). - // After that, position is managed by configureClient and - // VisualMoveCallback — don't overwrite during drag. - if u.wi.W == 0 { - u.wi.X = u.x - u.wi.Y = u.y - u.wi.W = u.w - u.wi.H = u.h - u.wi.Img.Move(fyne.NewPos(float32(u.x)/scale, float32(u.y)/scale)) - u.wi.Img.Resize(fyne.NewSize(float32(u.w)/scale, float32(u.h)/scale)) + for _, cap := range caps { + c := cap.c + winID := uint32(c.win) + isFS := isFullscreenClient(c) + + for _, sw := range ws.screensForClient(c) { + var target *ui.CompositorWidget + if isFS { + target = sw.overlay + } else { + target = sw.normal + } + wi := target.GetWindow(winID) + if wi == nil { + continue + } + + wi.Img.Image = cap.img + wi.Img.Translucency = cap.translucency + + localX := c.geom.X - int16(sw.screen.X) + localY := c.geom.Y - int16(sw.screen.Y) + scale := sw.screen.CanvasScale() + + // Set position/size if not yet initialized (first capture). + if wi.W == 0 { + wi.X = localX + wi.Y = localY + wi.W = cap.totalW + wi.H = cap.totalH + wi.Img.Move(fyne.NewPos(float32(localX)/scale, float32(localY)/scale)) + wi.Img.Resize(fyne.NewSize(float32(cap.totalW)/scale, float32(cap.totalH)/scale)) + } + canvas.Refresh(wi.Img) } - canvas.Refresh(u.wi.Img) } }) } @@ -540,17 +688,23 @@ func refreshTranslucency(conn *xgb.Conn, ws *widgets) { var updates []update for _, c := range clients { - if c.skipped || c.attributes.MapState != xproto.MapStateViewable { - continue - } - w := ws.targetFor(c) - wi := w.GetWindow(uint32(c.win)) - if wi == nil { + if c.skipped || c.fullscreened || c.attributes.MapState != xproto.MapStateViewable { continue } + winID := uint32(c.win) translucency := computeTranslucency(conn, c) - if wi.Img.Translucency != translucency { - updates = append(updates, update{wi, translucency}) + + for i := range ws.screens { + sw := &ws.screens[i] + for _, target := range []*ui.CompositorWidget{sw.normal, sw.overlay} { + wi := target.GetWindow(winID) + if wi == nil { + continue + } + if wi.Img.Translucency != translucency { + updates = append(updates, update{wi, translucency}) + } + } } } @@ -559,8 +713,7 @@ func refreshTranslucency(conn *xgb.Conn, ws *widgets) { for _, u := range updates { u.wi.Img.Translucency = u.translucency } - ws.normal.Refresh() - ws.overlay.Refresh() + ws.refreshAll() }) } } @@ -611,8 +764,14 @@ func isScreensaver(title string, attr *xproto.GetWindowAttributesReply, geom *xp } // Override-redirect windows covering a full screen are likely screensavers - if attr.OverrideRedirect && geom.Width >= rootWidth && geom.Height >= rootHeight { - return true + if attr.OverrideRedirect { + if inst := fynedesk.Instance(); inst != nil { + for _, screen := range inst.Screens().Screens() { + if geom.Width >= uint16(screen.Width) && geom.Height >= uint16(screen.Height) { + return true + } + } + } } return false @@ -704,9 +863,9 @@ func mapWin(conn *xgb.Conn, ws *widgets, window xproto.Window) error { freeClientPixmap(conn, c) if ws != nil && !c.skipped { - ws.targetFor(c).EnsureWindow(uint32(c.win)) + ensureWindowOnScreens(ws, c) syncOrder(ws) - ws.refreshBoth() + ws.refreshAll() } allDamage = true @@ -723,8 +882,8 @@ func unmapWin(conn *xgb.Conn, ws *widgets, window xproto.Window) { freeClientPixmap(conn, c) if ws != nil && !c.skipped { - ws.targetFor(c).RemoveWindow(uint32(c.win)) - ws.refreshBoth() + removeWindowFromAllScreens(ws, uint32(c.win)) + ws.refreshAll() } allDamage = true @@ -748,8 +907,8 @@ found: } if ws != nil && !c.skipped { - ws.targetFor(c).RemoveWindow(uint32(c.win)) - ws.refreshBoth() + removeWindowFromAllScreens(ws, uint32(c.win)) + ws.refreshAll() } clients = append(clients[:i], clients[i+1:]...) @@ -793,36 +952,90 @@ func configureClient(conn *xgb.Conn, ws *widgets, e xproto.ConfigureNotifyEvent) return nil } - syncOrder(ws) - ws.refreshBoth() - - updateFullscreen(ws, client) - - { - w := ws.targetFor(client) - wi := w.GetWindow(uint32(client.win)) - if wi != nil { - totalW := client.geom.Width + client.geom.BorderWidth*2 - totalH := client.geom.Height + client.geom.BorderWidth*2 - - if resized { - fyne.Do(func() { - wi.X = client.geom.X - wi.Y = client.geom.Y - wi.W = totalW - wi.H = totalH - w.Refresh() - }) - client.damaged = true - allDamage = true + updateFullscreen(conn, ws, client) + if client.fullscreened { + return nil + } + + // Update screen membership: remove from screens the window no longer overlaps, + // add to screens it now overlaps. + winID := uint32(client.win) + isFS := isFullscreenClient(client) + overlapping := ws.screensForClient(client) + overlapSet := make(map[*screenWidgets]bool, len(overlapping)) + for _, sw := range overlapping { + overlapSet[sw] = true + } + screenChanged := false + for i := range ws.screens { + sw := &ws.screens[i] + hasNormal := sw.normal.GetWindow(winID) != nil + hasOverlay := sw.overlay.GetWindow(winID) != nil + if !overlapSet[sw] { + if hasNormal { + sw.normal.RemoveWindow(winID) + } + if hasOverlay { + sw.overlay.RemoveWindow(winID) + } + } else if !hasNormal && !hasOverlay { + // Window appeared on a new screen — create the entry and + // copy image data from whichever screen previously had it. + var wi *ui.WindowImage + if isFS { + wi = sw.overlay.EnsureWindow(winID) } else { - wi.X = client.geom.X - wi.Y = client.geom.Y - scale := screenScale() - fyne.Do(func() { - wi.Img.Move(fyne.NewPos(float32(client.geom.X)/scale, float32(client.geom.Y)/scale)) - }) + wi = sw.normal.EnsureWindow(winID) } + copyImageFromOtherScreen(ws, winID, wi, sw) + screenChanged = true + } + } + + if screenChanged { + // Force recapture so the new screen gets a fresh image + client.damaged = true + allDamage = true + } + + syncOrder(ws) + ws.refreshAll() + + totalW := client.geom.Width + client.geom.BorderWidth*2 + totalH := client.geom.Height + client.geom.BorderWidth*2 + + for _, sw := range overlapping { + var target *ui.CompositorWidget + if isFS { + target = sw.overlay + } else { + target = sw.normal + } + wi := target.GetWindow(winID) + if wi == nil { + continue + } + + localX := client.geom.X - int16(sw.screen.X) + localY := client.geom.Y - int16(sw.screen.Y) + + if resized || screenChanged { + fyne.Do(func() { + wi.X = localX + wi.Y = localY + wi.W = totalW + wi.H = totalH + target.Refresh() + }) + client.damaged = true + allDamage = true + } else { + wi.X = localX + wi.Y = localY + scale := sw.screen.CanvasScale() + fyne.Do(func() { + wi.Img.Move(fyne.NewPos(float32(localX)/scale, float32(localY)/scale)) + }) } } @@ -866,7 +1079,7 @@ func restackWin(ws *widgets, window, target xproto.Window) { if ws != nil { syncOrder(ws) - ws.refreshBoth() + ws.refreshAll() } } diff --git a/internal/x11/win/client.go b/internal/x11/win/client.go index 838bb16d5..186a4b9fd 100644 --- a/internal/x11/win/client.go +++ b/internal/x11/win/client.go @@ -392,12 +392,18 @@ func (c *client) QueueMoveResizeGeometry(x int, y int, width uint, height uint) } func (c *client) RaiseAbove(win fynedesk.Window) { - topID := c.wm.RootID() - if win != nil { - topID = win.(*client).id + c.Focus() + + if win == nil { + // No sibling specified — raise to the top of the stack. + // With per-screen root windows, sibling-based stacking relative + // to a single root can place the frame behind another screen's root. + xproto.ConfigureWindow(c.wm.Conn(), c.id, xproto.ConfigWindowStackMode, + []uint32{uint32(xproto.StackModeAbove)}) + return } - c.Focus() + topID := win.(*client).id if c.id == topID { return } diff --git a/internal/x11/win/frame.go b/internal/x11/win/frame.go index 7b7a1c045..025b018db 100644 --- a/internal/x11/win/frame.go +++ b/internal/x11/win/frame.go @@ -177,6 +177,7 @@ func newFrame(c *client) *frame { } windowStateSet(c.wm.X(), c.win, icccm.StateNormal) + c.frame = framed // set early so ScreenForWindow can resolve the correct screen framed.show() framed.applyTheme(true) framed.notifyInnerGeometry() diff --git a/internal/x11/wm/desk.go b/internal/x11/wm/desk.go index b906f1fc6..140a809cb 100644 --- a/internal/x11/wm/desk.go +++ b/internal/x11/wm/desk.go @@ -59,7 +59,7 @@ type x11WM struct { currentBindings []*fynedesk.Shortcut died bool - rootID xproto.Window + rootIDs map[string]xproto.Window menuSize fyne.Size menuPos fyne.Position transientMap map[xproto.Window][]xproto.Window @@ -119,6 +119,7 @@ func NewX11WindowManager(a fyne.App) (fynedesk.WindowManager, error) { mgr := &x11WM{x: conn} root := conn.RootWin() mgr.takeSelectionOwnership() + mgr.rootIDs = make(map[string]xproto.Window) mgr.transientMap = make(map[xproto.Window][]xproto.Window) eventMask := xproto.EventMaskPropertyChange | @@ -248,8 +249,11 @@ func (x *x11WM) SetOverlayActive(active bool) { x.updateRootInputShape(active) x.updateFrameInputShapes(active) - if active && x.rootID != 0 { - xproto.SetInputFocus(x.x.Conn(), xproto.InputFocusPointerRoot, x.rootID, xproto.TimeCurrentTime) + if active { + // Focus the primary root window so overlays receive keyboard events + if primary := x.RootID(); primary != 0 { + xproto.SetInputFocus(x.x.Conn(), xproto.InputFocusPointerRoot, primary, xproto.TimeCurrentTime) + } } } @@ -309,18 +313,28 @@ func (x *x11WM) updateFrameInputShapes(overlayActive bool) { } } -// updateRootInputShape sets the root window's X11 input shape. -// The root window always accepts input everywhere — it sits below all -// frame windows in the stacking order, so frames naturally receive events -// in their areas. Frame input shapes (managed by updateFrameInputShapes) -// exclude panel regions so that panels always remain clickable. +// updateRootInputShape sets the input shape on all root windows. +// The primary root accepts input everywhere (for panels and desktop interaction). +// Secondary roots accept no input — they are purely visual; all mouse events +// on secondary screens should go to the X11 frame windows above them. func (x *x11WM) updateRootInputShape(_ bool) { - if x.rootID == 0 { - return + inst := fynedesk.Instance() + var primaryName string + if inst != nil && inst.Screens().Primary() != nil { + primaryName = inst.Screens().Primary().Name } - // Reset input shape to default (full window) - shape.Mask(x.x.Conn(), shape.SoSet, shape.SkInput, x.rootID, 0, 0, xproto.PixmapNone) + emptyRect := []xproto.Rectangle{{X: 0, Y: 0, Width: 0, Height: 0}} + for name, rootID := range x.rootIDs { + if name == primaryName { + // Primary root: accept input everywhere (for bar, widgets, desktop) + shape.Mask(x.x.Conn(), shape.SoSet, shape.SkInput, rootID, 0, 0, xproto.PixmapNone) + } else { + // Secondary roots: no input — frames handle all events + shape.Rectangles(x.x.Conn(), shape.SoSet, shape.SkInput, + 0, rootID, 0, 0, emptyRect) + } + } } func (x *x11WM) ShowOverlay(w fyne.Window, s fyne.Size, p fyne.Position) { @@ -561,53 +575,58 @@ func (x *x11WM) configureRoots() { maxX = max(maxX, screen.X+screen.Width) maxY = max(maxY, screen.Y+screen.Height) - if screen == fynedesk.Instance().Screens().Primary() { - priX, priY, priW, priH := 0, 0, 0, 0 - geom, err := xproto.GetGeometry(x.x.Conn(), xproto.Drawable(x.rootID)).Reply() - if err == nil { - priX, priY = int(geom.X), int(geom.Y) - priW, priH = int(geom.Width), int(geom.Height) - } - if screen.X == priX && screen.Y == priY && screen.Width == priW && screen.Height == priH { - continue - } - - notifyEv := xproto.ConfigureNotifyEvent{ - Event: x.rootID, Window: x.rootID, AboveSibling: 0, - X: int16(screen.X), Y: int16(screen.Y), Width: uint16(screen.Width), Height: uint16(screen.Height), - BorderWidth: 0, OverrideRedirect: false, - } - xproto.SendEvent(x.x.Conn(), false, x.rootID, xproto.EventMaskStructureNotify, string(notifyEv.Bytes())) + rootID := x.rootIDs[screen.Name] + if rootID == 0 { + continue + } - // we need to trigger a move so that the correct scale is picked up - xproto.ConfigureWindow(x.x.Conn(), x.rootID, xproto.ConfigWindowX|xproto.ConfigWindowY| - xproto.ConfigWindowWidth|xproto.ConfigWindowHeight, - []uint32{uint32(screen.X + 1), uint32(screen.Y + 1), uint32(screen.Width - 2), uint32(screen.Height - 2)}) + // Check if this root is already at the right geometry + priX, priY, priW, priH := 0, 0, 0, 0 + geom, err := xproto.GetGeometry(x.x.Conn(), xproto.Drawable(rootID)).Reply() + if err == nil { + priX, priY = int(geom.X), int(geom.Y) + priW, priH = int(geom.Width), int(geom.Height) + } + if screen.X == priX && screen.Y == priY && screen.Width == priW && screen.Height == priH { + continue + } - // and then set the correct location - xproto.ConfigureWindow(x.x.Conn(), x.rootID, xproto.ConfigWindowX|xproto.ConfigWindowY| - xproto.ConfigWindowWidth|xproto.ConfigWindowHeight, - []uint32{uint32(screen.X), uint32(screen.Y), uint32(screen.Width), uint32(screen.Height)}) + notifyEv := xproto.ConfigureNotifyEvent{ + Event: rootID, Window: rootID, AboveSibling: 0, + X: int16(screen.X), Y: int16(screen.Y), Width: uint16(screen.Width), Height: uint16(screen.Height), + BorderWidth: 0, OverrideRedirect: false, } + xproto.SendEvent(x.x.Conn(), false, rootID, xproto.EventMaskStructureNotify, string(notifyEv.Bytes())) + + // Trigger a move so that the correct scale is picked up + xproto.ConfigureWindow(x.x.Conn(), rootID, xproto.ConfigWindowX|xproto.ConfigWindowY| + xproto.ConfigWindowWidth|xproto.ConfigWindowHeight, + []uint32{uint32(screen.X + 1), uint32(screen.Y + 1), uint32(screen.Width - 2), uint32(screen.Height - 2)}) + + // Then set the correct location + xproto.ConfigureWindow(x.x.Conn(), rootID, xproto.ConfigWindowX|xproto.ConfigWindowY| + xproto.ConfigWindowWidth|xproto.ConfigWindowHeight, + []uint32{uint32(screen.X), uint32(screen.Y), uint32(screen.Width), uint32(screen.Height)}) + } + + // Always ensure root windows stay at the bottom of the X11 stack. + // Fyne/GLFW may re-raise them during window creation or configuration. + for _, rootID := range x.rootIDs { + xproto.ConfigureWindow(x.x.Conn(), rootID, + xproto.ConfigWindowStackMode, []uint32{uint32(xproto.StackModeBelow)}) } rootWidth := maxX - minX rootHeight := maxY - minY - // Now extend the root window to span all screens for the compositor - if x.rootID != 0 { - xproto.ConfigureWindow(x.x.Conn(), x.rootID, xproto.ConfigWindowX|xproto.ConfigWindowY| - xproto.ConfigWindowWidth|xproto.ConfigWindowHeight, - []uint32{uint32(minX), uint32(minY), uint32(rootWidth), uint32(rootHeight)}) - x.updateRootInputShape(false) // reapply panel-only input shape after resize - } + x.updateRootInputShape(false) // reapply input shape after resize - err := ewmh.DesktopGeometrySet(x.x, &ewmh.DesktopGeometry{Width: rootWidth, Height: rootHeight}) // The size will grow when virtual desktops are supported + err := ewmh.DesktopGeometrySet(x.x, &ewmh.DesktopGeometry{Width: rootWidth, Height: rootHeight}) if err != nil { fyne.LogError("", err) } - err = ewmh.WorkareaSet(x.x, []ewmh.Workarea{{X: 0, Y: 0, Width: uint(rootWidth), Height: uint(rootHeight)}}) // The array will grow when virtual desktops are supported + err = ewmh.WorkareaSet(x.x, []ewmh.Workarea{{X: 0, Y: 0, Width: uint(rootWidth), Height: uint(rootHeight)}}) if err != nil { fyne.LogError("", err) } @@ -621,6 +640,15 @@ func (x *x11WM) notifyConfigure(ev xproto.ConfigureNotifyEvent) { } func (x *x11WM) configureWindow(win xproto.Window, ev xproto.ConfigureRequestEvent) { + // Check if this is a root window first — Fyne/GLFW may send configure + // requests (including restacking) that we must intercept to keep roots below. + for _, rootID := range x.rootIDs { + if rootID == win { + x.configureRoots() + return + } + } + c := x.clientForWin(win) xcoord := ev.X ycoord := ev.Y @@ -652,7 +680,8 @@ func (x *x11WM) configureWindow(win xproto.Window, ev xproto.ConfigureRequestEve name := x11.WindowName(x.x, win) if x.isRootTitle(name) { - x.rootID = win + screenName := screenNameFromRootTitle(name) + x.rootIDs[screenName] = win x.configureRoots() // we added a root window, so reconfigure return @@ -715,6 +744,17 @@ func (x *x11WM) frameExisting() { if x.isRootTitle(name) { continue } + // Also skip by window ID — the title may not be set yet + isRoot := false + for _, rootID := range x.rootIDs { + if rootID == child { + isRoot = true + break + } + } + if isRoot { + continue + } attrs, err := xproto.GetWindowAttributes(x.x.Conn(), child).Reply() if err != nil { fyne.LogError("Get Window Attributes Error", err) @@ -728,7 +768,18 @@ func (x *x11WM) frameExisting() { } func (x *x11WM) RootID() xproto.Window { - return x.rootID + if fynedesk.Instance() == nil { + return 0 + } + primary := fynedesk.Instance().Screens().Primary() + if primary == nil { + return 0 + } + return x.rootIDs[primary.Name] +} + +func (x *x11WM) RootIDForScreen(screenName string) xproto.Window { + return x.rootIDs[screenName] } func (x *x11WM) NotifyWindowMoved(win fynedesk.Window) { @@ -786,14 +837,18 @@ func (x *x11WM) setInitialWindowAttributes(win xproto.Window) { func (x *x11WM) setupBindings() { fynedesk.Instance().Settings().AddChangeListener(func(_ fynedesk.DeskSettings) { // this uses the state from the previous bind call - x.unbindShortcuts(x.rootID) + for _, rootID := range x.rootIDs { + x.unbindShortcuts(rootID) + } for _, c := range x.clients { x.unbindShortcuts(c.(x11.XWin).ChildID()) } x.currentBindings = nil // this call sets up the new cache of shortcuts - x.bindShortcuts(x.rootID) + for _, rootID := range x.rootIDs { + x.bindShortcuts(rootID) + } for _, c := range x.clients { x.bindShortcuts(c.(x11.XWin).ChildID()) } @@ -821,7 +876,15 @@ func (x *x11WM) setupWindow(win xproto.Window) { x.AddWindow(c) }) c.RaiseToTop() + // Ensure the frame is above all root windows and has focus. + // RaiseToTop may skip the X11 restack when the internal client list + // hasn't been updated yet (AddWindow is queued via fyne.Do), and + // Focus() sends an async client message that GLFW may override. + xproto.ConfigureWindow(x.x.Conn(), c.(x11.XWin).FrameID(), + xproto.ConfigWindowStackMode, []uint32{uint32(xproto.StackModeAbove)}) c.Focus() + xproto.SetInputFocus(x.x.Conn(), xproto.InputFocusPointerRoot, + c.(x11.XWin).ChildID(), xproto.TimeCurrentTime) windowClientListUpdate(x) windowClientListStackingUpdate(x) } @@ -835,17 +898,36 @@ func (x *x11WM) setupX11DPIHints() { } func (x *x11WM) showWindow(win xproto.Window, parent xproto.Window) { + // If the parent is a frame (not the X root), the window is already + // reparented — just map it. This avoids re-framing a window when + // frame.show() maps the client inside a frame with SubstructureRedirect. + if parent != x.x.RootWin() { + xproto.MapWindow(x.x.Conn(), win) + return + } + name := x11.WindowName(x.x, win) if x.isRootTitle(name) { + screenName := screenNameFromRootTitle(name) + x.rootIDs[screenName] = win + err := xproto.MapWindowChecked(x.x.Conn(), win).Check() if err != nil { fyne.LogError("Show Window Error", err) } xproto.ConfigureWindow(x.x.Conn(), win, xproto.ConfigWindowStackMode, []uint32{xproto.StackModeBelow}) - x.updateRootInputShape(false) // set input shape to panel areas + x.configureRoots() // position all roots at their screen geometry immediately _ = ewmh.WmWindowTypeSet(x.x, win, []string{windowTypeDesktop}) x.bindShortcuts(win) - if !x.framedExisting { + + // Only frame existing windows once ALL root windows have been shown. + // If we frame too early, secondary root windows may not have their + // title set yet and would be accidentally framed as client windows. + expectedRoots := 1 + if inst := fynedesk.Instance(); inst != nil { + expectedRoots = len(inst.Screens().Screens()) + } + if !x.framedExisting && len(x.rootIDs) >= expectedRoots { x.framedExisting = true go x.frameExisting() } diff --git a/internal/x11/wm/stack.go b/internal/x11/wm/stack.go index fad4af1ba..e7b657a6f 100644 --- a/internal/x11/wm/stack.go +++ b/internal/x11/wm/stack.go @@ -68,7 +68,7 @@ func (s *stack) RemoveWindow(win fynedesk.Window) { } else { // focus root if wm := fynedesk.Instance().WindowManager().(*x11WM); wm.X() != nil { - err := ewmh.ActiveWindowReq(wm.X(), wm.rootID) + err := ewmh.ActiveWindowReq(wm.X(), wm.RootID()) if err != nil { fyne.LogError("There was an error trying to remove the window ", err) } diff --git a/internal/x11/xwm.go b/internal/x11/xwm.go index 9b96ade5c..a0c8a985b 100644 --- a/internal/x11/xwm.go +++ b/internal/x11/xwm.go @@ -19,4 +19,5 @@ type XWM interface { Conn() *xgb.Conn RootID() xproto.Window + RootIDForScreen(screenName string) xproto.Window } diff --git a/modules/quaketerm/term.go b/modules/quaketerm/term.go index c3c530678..e3dcc5f8d 100644 --- a/modules/quaketerm/term.go +++ b/modules/quaketerm/term.go @@ -69,15 +69,12 @@ func (t *term) createTerm() { } func (t *term) hide() { - screen := fynedesk.Instance().Screens().Primary() - scale := screen.CanvasScale() - left := float32(screen.X) / scale - y := float32(screen.Y) / scale - end := float32(screen.Y)/scale - height + var y float32 + end := -float32(height) for y > end { currY := y fyne.Do(func() { - t.content.Move(fyne.NewPos(left, currY)) + t.content.Move(fyne.NewPos(0, currY)) }) time.Sleep(delay) y -= step @@ -91,12 +88,11 @@ func (t *term) show() { screen := fynedesk.Instance().Screens().Primary() scale := screen.CanvasScale() w := float32(screen.Width) / scale - left := float32(screen.X) / scale - y := float32(screen.Y)/scale - height - end := float32(screen.Y) / scale + y := -float32(height) + var end float32 size := fyne.NewSize(w, height) - fynedesk.Instance().ShowOverlay(t.content, size, fyne.NewPos(left, y)) + fynedesk.Instance().ShowOverlay(t.content, size, fyne.NewPos(0, y)) if !t.running { t.running = true @@ -117,13 +113,13 @@ func (t *term) show() { currY := y fyne.Do(func() { t.content.Resize(size) - t.content.Move(fyne.NewPos(left, currY)) + t.content.Move(fyne.NewPos(0, currY)) }) time.Sleep(delay) y += step } fyne.Do(func() { - t.content.Move(fyne.NewPos(left, end)) + t.content.Move(fyne.NewPos(0, end)) fynedesk.Instance().Root().Canvas().Focus(t.console) }) t.shown = true From ba81483b255bd78a38d3ad57cae5db07111e9384 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Tue, 7 Apr 2026 21:13:57 +0100 Subject: [PATCH 05/12] Fix multi-monitor position for largetype --- modules/launcher/largetype.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/launcher/largetype.go b/modules/launcher/largetype.go index 9d9635c19..0e9aa9c49 100644 --- a/modules/launcher/largetype.go +++ b/modules/launcher/largetype.go @@ -78,8 +78,6 @@ func (i *largeTypeItem) Launch() { screenW := float32(screen.Width) / scale screenH := float32(screen.Height) / scale - screenX := float32(screen.X) / scale - screenY := float32(screen.Y) / scale label := canvas.NewText(i.text, theme.Color(theme.ColorNameForeground)) label.TextSize = 120 @@ -97,7 +95,7 @@ func (i *largeTypeItem) Launch() { overlay = container.NewStack(canvas.NewBlur(5), bg, dismiss, container.NewCenter(label)) size := fyne.NewSize(screenW, screenH) - pos := fyne.NewPos(screenX, screenY) + pos := fyne.NewPos(0, 0) desk.ShowOverlay(overlay, size, pos) fyne.Do(func() { desk.Root().Canvas().Focus(dismiss) From 470e35d1d89ff62d6107258214048ad7d8850cb0 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Tue, 7 Apr 2026 21:22:10 +0100 Subject: [PATCH 06/12] Remove unneeded type conversion --- internal/x11/wm/desk.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/x11/wm/desk.go b/internal/x11/wm/desk.go index 140a809cb..23b1365fc 100644 --- a/internal/x11/wm/desk.go +++ b/internal/x11/wm/desk.go @@ -880,11 +880,11 @@ func (x *x11WM) setupWindow(win xproto.Window) { // RaiseToTop may skip the X11 restack when the internal client list // hasn't been updated yet (AddWindow is queued via fyne.Do), and // Focus() sends an async client message that GLFW may override. - xproto.ConfigureWindow(x.x.Conn(), c.(x11.XWin).FrameID(), + xproto.ConfigureWindow(x.x.Conn(), c.FrameID(), xproto.ConfigWindowStackMode, []uint32{uint32(xproto.StackModeAbove)}) c.Focus() xproto.SetInputFocus(x.x.Conn(), xproto.InputFocusPointerRoot, - c.(x11.XWin).ChildID(), xproto.TimeCurrentTime) + c.ChildID(), xproto.TimeCurrentTime) windowClientListUpdate(x) windowClientListStackingUpdate(x) } From a0f454599b7379132280149bd56649a296bffb55 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sat, 11 Apr 2026 20:02:21 +0100 Subject: [PATCH 07/12] Offer a web search fallback --- modules/launcher/init.go | 1 + modules/launcher/search.go | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 modules/launcher/search.go diff --git a/modules/launcher/init.go b/modules/launcher/init.go index ec60cc082..c3d159906 100644 --- a/modules/launcher/init.go +++ b/modules/launcher/init.go @@ -5,6 +5,7 @@ import "fyshos.com/fynedesk" func init() { fynedesk.RegisterModule(calcMeta) fynedesk.RegisterModule(largeTypeMeta) + fynedesk.RegisterModule(searchMeta) fynedesk.RegisterModule(urlMeta) fynedesk.RegisterModule(unytsMeta) } diff --git a/modules/launcher/search.go b/modules/launcher/search.go new file mode 100644 index 000000000..aa859c23b --- /dev/null +++ b/modules/launcher/search.go @@ -0,0 +1,66 @@ +package launcher + +import ( + "net/url" + "runtime/debug" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" + + "fyshos.com/fynedesk" +) + +var searchMeta = fynedesk.ModuleMetadata{ + Name: "Launcher: Web Search", + NewInstance: newSearchSuggest, +} + +type search struct{} + +func (s *search) Destroy() { +} + +func (s *search) LaunchSuggestions(input string) []fynedesk.LaunchSuggestion { + if input == "" { + return nil + } + return []fynedesk.LaunchSuggestion{&searchItem{text: input}} +} + +func (s *search) Metadata() fynedesk.ModuleMetadata { + return searchMeta +} + +// isExpression will return true if input is a mathematical expression unless it just contains a number +func (s *search) isExpression(input string) bool { + return true +} + +// newCalcSuggest creates a new module that will show an option to search the web for an string. +func newSearchSuggest() fynedesk.Module { + return &search{} +} + +type searchItem struct { + text string +} + +func (s *searchItem) Icon() fyne.Resource { + return theme.SearchIcon() +} + +func (s *searchItem) Title() string { + return "Search in Duck Duck Go" +} + +func (s *searchItem) Launch() { + enc := url.QueryEscape(s.text) + u, err := url.Parse("https://duck.com/?q=" + enc) + if err != nil { + fyne.LogError("Failed to set up web search", err) + return + } + + debug.PrintStack() + _ = fyne.CurrentApp().OpenURL(u) +} From 5bcf0dfc1099c1026dca837e90ce29b52f2d5f3b Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sat, 11 Apr 2026 20:35:32 +0100 Subject: [PATCH 08/12] Don't remove caches during virtual desktop switch --- internal/x11/composit/gocompositor.go | 65 +++++++++------------------ 1 file changed, 22 insertions(+), 43 deletions(-) diff --git a/internal/x11/composit/gocompositor.go b/internal/x11/composit/gocompositor.go index 0eea485f8..29c0b0804 100644 --- a/internal/x11/composit/gocompositor.go +++ b/internal/x11/composit/gocompositor.go @@ -176,33 +176,33 @@ func Run(done chan struct{}, screenComps []ui.ScreenCompositors) error { c.visualMoving = true } - // Route the window to all screens it overlaps, remove from others + // Update position on all screens that have a cached entry, and + // add to new screens if the window now overlaps them. for i := range ws.screens { sw := &ws.screens[i] - if intersectsScreen(absX, absY, width, height, sw.screen) { - // Find or create the window image in this screen's widget - for _, target := range []*ui.CompositorWidget{sw.normal, sw.overlay} { - wi := target.GetWindow(winID) - if wi == nil { - // Check the other widget on this screen - continue - } - localX := absX - int16(sw.screen.X) - localY := absY - int16(sw.screen.Y) - wi.X = localX - wi.Y = localY - wi.W = width - wi.H = height - scale := sw.screen.CanvasScale() - wi.Img.Move(fyne.NewPos(float32(localX)/scale, float32(localY)/scale)) + localX := absX - int16(sw.screen.X) + localY := absY - int16(sw.screen.Y) + + // Always update position for existing cached entries so + // windows animate smoothly even as they leave the screen. + for _, target := range []*ui.CompositorWidget{sw.normal, sw.overlay} { + wi := target.GetWindow(winID) + if wi == nil { + continue } - // If not in any widget on this screen yet, ensure it + wi.X = localX + wi.Y = localY + wi.W = width + wi.H = height + scale := sw.screen.CanvasScale() + wi.Img.Move(fyne.NewPos(float32(localX)/scale, float32(localY)/scale)) + } + + // If the window newly overlaps this screen and has no entry, create one. + if intersectsScreen(absX, absY, width, height, sw.screen) { if sw.normal.GetWindow(winID) == nil && sw.overlay.GetWindow(winID) == nil { wi := sw.normal.EnsureWindow(winID) - // Copy image from another screen that has it copyImageFromOtherScreen(ws, winID, wi, sw) - localX := absX - int16(sw.screen.X) - localY := absY - int16(sw.screen.Y) wi.X = localX wi.Y = localY wi.W = width @@ -212,16 +212,6 @@ func Run(done chan struct{}, screenComps []ui.ScreenCompositors) error { wi.Img.Resize(fyne.NewSize(float32(width)/scale, float32(height)/scale)) sw.normal.Refresh() } - } else { - // Remove from this screen if present - if sw.normal.GetWindow(winID) != nil { - sw.normal.RemoveWindow(winID) - sw.normal.Refresh() - } - if sw.overlay.GetWindow(winID) != nil { - sw.overlay.RemoveWindow(winID) - sw.overlay.Refresh() - } } } } @@ -962,23 +952,12 @@ func configureClient(conn *xgb.Conn, ws *widgets, e xproto.ConfigureNotifyEvent) winID := uint32(client.win) isFS := isFullscreenClient(client) overlapping := ws.screensForClient(client) - overlapSet := make(map[*screenWidgets]bool, len(overlapping)) - for _, sw := range overlapping { - overlapSet[sw] = true - } screenChanged := false for i := range ws.screens { sw := &ws.screens[i] hasNormal := sw.normal.GetWindow(winID) != nil hasOverlay := sw.overlay.GetWindow(winID) != nil - if !overlapSet[sw] { - if hasNormal { - sw.normal.RemoveWindow(winID) - } - if hasOverlay { - sw.overlay.RemoveWindow(winID) - } - } else if !hasNormal && !hasOverlay { + if !hasNormal && !hasOverlay { // Window appeared on a new screen — create the entry and // copy image data from whichever screen previously had it. var wi *ui.WindowImage From 18aed9837ff2b8321f8d5c3dea2b54fe4422f19a Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sat, 11 Apr 2026 20:43:15 +0100 Subject: [PATCH 09/12] Clean up menu sizing and some errors --- internal/ui/desk_full.go | 3 +-- internal/ui/screensaver.go | 2 +- modules/systray/main.go | 16 +++++----------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/internal/ui/desk_full.go b/internal/ui/desk_full.go index e21c27fa6..f7a3bf136 100644 --- a/internal/ui/desk_full.go +++ b/internal/ui/desk_full.go @@ -10,7 +10,6 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" - wmtheme "fyshos.com/fynedesk/theme" ) func (l *desktop) runFull() { @@ -36,7 +35,7 @@ func (l *desktop) runFull() { func (l *desktop) showMenuFull(menu *fyne.Menu, pos fyne.Position) { menuSize := widget.NewMenu(menu).MinSize() - size := fyne.NewSize(wmtheme.WidgetPanelWidth, menuSize.Height) + size := menuSize // Measure child menus to calculate the total hover-catch area. // The submenu appears to the right by default but Fyne flips it diff --git a/internal/ui/screensaver.go b/internal/ui/screensaver.go index b618ca78f..a857f7d8f 100644 --- a/internal/ui/screensaver.go +++ b/internal/ui/screensaver.go @@ -82,7 +82,7 @@ func watchDBus() { name := "org.freedesktop.ScreenSaver" r, err := conn.RequestName(name, dbus.NameFlagDoNotQueue) if err != nil || r != dbus.RequestNameReplyPrimaryOwner { - fyne.LogError("could not watch DBus screensaver, another is registered", err) + fyne.LogError("Could not watch DBus screensaver, another is registered", err) return } diff --git a/modules/systray/main.go b/modules/systray/main.go index 1a53d8460..2cb9e2db0 100644 --- a/modules/systray/main.go +++ b/modules/systray/main.go @@ -118,9 +118,9 @@ func NewTray() fynedesk.Module { } // End TODO - hostErr := t.RegisterStatusNotifierHost(conn.Names()[0], "") + hostErr := t.RegisterStatusNotifierHost(conn.Names()[0]) if hostErr != nil { - fyne.LogError("Failed to register our host with the notifier watcher, maybe no watcher running? %v", hostErr) + fyne.LogError("Failed to register our systray host, another may already be running", hostErr) return t } @@ -163,7 +163,7 @@ func NewTray() fynedesk.Module { func (t *tray) Destroy() { } -func (t *tray) RegisterStatusNotifierItem(service string, sender dbus.Sender) (err *dbus.Error) { +func (t *tray) RegisterStatusNotifierItem(service string, sender dbus.Sender) error { ni := notifier.NewStatusNotifierItem(t.conn.Object(string(sender), dbus.ObjectPath(service))) item, ok := t.nodes[sender] @@ -201,17 +201,11 @@ func (t *tray) RegisterStatusNotifierItem(service string, sender dbus.Sender) (e return nil } -func (t *tray) RegisterStatusNotifierHost(service string, sender dbus.Sender) (err *dbus.Error) { - log.Println("Register Host", service, sender) - - e := watcher.Emit(t.conn, &watcher.StatusNotifierWatcher_StatusNotifierHostRegisteredSignal{ +func (t *tray) RegisterStatusNotifierHost(service string) error { + return watcher.Emit(t.conn, &watcher.StatusNotifierWatcher_StatusNotifierHostRegisteredSignal{ Path: dbus.ObjectPath(service), Body: &watcher.StatusNotifierWatcher_StatusNotifierHostRegisteredSignalBody{}, }) - if e != nil { - fyne.LogError("it was not emit the notification ", err) - } - return nil } func (t *tray) Metadata() fynedesk.ModuleMetadata { From 4fcc1760bf018898aec976b6b0db4d7b195fb8f1 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sun, 12 Apr 2026 17:54:12 +0100 Subject: [PATCH 10/12] Use correct icon and remove unused method --- modules/launcher/search.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/modules/launcher/search.go b/modules/launcher/search.go index aa859c23b..5270da58f 100644 --- a/modules/launcher/search.go +++ b/modules/launcher/search.go @@ -5,7 +5,7 @@ import ( "runtime/debug" "fyne.io/fyne/v2" - "fyne.io/fyne/v2/theme" + wmTheme "fyshos.com/fynedesk/theme" "fyshos.com/fynedesk" ) @@ -31,11 +31,6 @@ func (s *search) Metadata() fynedesk.ModuleMetadata { return searchMeta } -// isExpression will return true if input is a mathematical expression unless it just contains a number -func (s *search) isExpression(input string) bool { - return true -} - // newCalcSuggest creates a new module that will show an option to search the web for an string. func newSearchSuggest() fynedesk.Module { return &search{} @@ -46,7 +41,7 @@ type searchItem struct { } func (s *searchItem) Icon() fyne.Resource { - return theme.SearchIcon() + return wmTheme.InternetIcon } func (s *searchItem) Title() string { From fdf8fe7e5a6eb0f8f33e6892c792af9bb732c149 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sun, 12 Apr 2026 18:04:13 +0100 Subject: [PATCH 11/12] Ensure search items are at the end --- internal/ui/launcher.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/ui/launcher.go b/internal/ui/launcher.go index 68b27dc41..6a6bab845 100644 --- a/internal/ui/launcher.go +++ b/internal/ui/launcher.go @@ -2,6 +2,7 @@ package ui import ( "image/color" + "strings" "github.com/FyshOS/appie" @@ -182,7 +183,7 @@ func (l *picker) loadIcons(dataRange []appie.AppData, appList []fyne.CanvasObjec } func (l *picker) loadSuggestionsMatching(input string) []fyne.CanvasObject { - var suggestList []fyne.CanvasObject + var suggestList, searchList []fyne.CanvasObject for _, m := range l.desk.Modules() { suggest, ok := m.(fynedesk.LaunchSuggestionModule) @@ -197,11 +198,18 @@ func (l *picker) loadSuggestionsMatching(input string) []fyne.CanvasObject { launchData.Launch() }) - suggestList = append(suggestList, button) + if strings.Contains(strings.ToLower(m.Metadata().Name), "search") { + searchList = append(searchList, button) + } else { + suggestList = append(suggestList, button) + } } } - return suggestList + if len(searchList) == 0 { + return searchList + } + return append(suggestList, searchList...) } func newAppPicker(callback func(appie.AppData, int)) *picker { From d862a8e0ff9c775d9b29f7efc2ebae689d7cd45a Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Sun, 12 Apr 2026 18:09:19 +0100 Subject: [PATCH 12/12] Add some tests for the new modules --- modules/launcher/largetype_test.go | 48 ++++++++++++++++++++++++++++++ modules/launcher/search_test.go | 25 ++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 modules/launcher/largetype_test.go create mode 100644 modules/launcher/search_test.go diff --git a/modules/launcher/largetype_test.go b/modules/launcher/largetype_test.go new file mode 100644 index 000000000..a5498324d --- /dev/null +++ b/modules/launcher/largetype_test.go @@ -0,0 +1,48 @@ +package launcher + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLargeType_LaunchSuggestions_NoMatch(t *testing.T) { + l := newLargeType().(*largeType) + + assert.Nil(t, l.LaunchSuggestions("hello world")) + assert.Nil(t, l.LaunchSuggestions("xyz")) +} + +func TestLargeType_LaunchSuggestions_AliasPrefix(t *testing.T) { + l := newLargeType().(*largeType) + + for _, alias := range largeTypeAliases { + res := l.LaunchSuggestions(alias) + if assert.Len(t, res, 1) { + item := res[0].(*largeTypeItem) + assert.Equal(t, "", item.text) + } + } + + // partial prefix still matches + res := l.LaunchSuggestions("lar") + if assert.Len(t, res, 1) { + assert.Equal(t, "", res[0].(*largeTypeItem).text) + } +} + +func TestLargeType_LaunchSuggestions_WithText(t *testing.T) { + l := newLargeType().(*largeType) + + res := l.LaunchSuggestions("largetype Hello World") + if assert.Len(t, res, 1) { + item := res[0].(*largeTypeItem) + assert.Equal(t, "Hello World", item.text) + assert.Equal(t, "Large Type: Hello World", item.Title()) + } + + res = l.LaunchSuggestions("BIG Fyne") + if assert.Len(t, res, 1) { + assert.Equal(t, "Fyne", res[0].(*largeTypeItem).text) + } +} diff --git a/modules/launcher/search_test.go b/modules/launcher/search_test.go new file mode 100644 index 000000000..27b86aa26 --- /dev/null +++ b/modules/launcher/search_test.go @@ -0,0 +1,25 @@ +package launcher + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSearch_LaunchSuggestions_Empty(t *testing.T) { + s := newSearchSuggest().(*search) + + assert.Nil(t, s.LaunchSuggestions("")) +} + +func TestSearch_LaunchSuggestions(t *testing.T) { + s := newSearchSuggest().(*search) + + res := s.LaunchSuggestions("fyne toolkit") + if assert.Len(t, res, 1) { + item := res[0].(*searchItem) + assert.Equal(t, "fyne toolkit", item.text) + assert.Equal(t, "Search in Duck Duck Go", item.Title()) + assert.NotNil(t, item.Icon()) + } +}