feat(core): fullscreen windows that map at the output size
- commit
- 43dcf6e780863b56b8d9c40cdf554bfd0b52d844
- author
- brice · · 2026-10-22 19:43 UTC
- parent
- fdd661a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +139 -3
M docs/config.md +1 -0
| @@ -204,6 +204,7 @@ A swipe moves one step at most. Other gestures (pinch, hold, two-finger scroll) | |||
| 204 | 204 | - HDR-aware apps (games, browsers, video players) can show HDR content. Some need HDR turned on in their own settings. X11 apps stay SDR. | |
| 205 | 205 | - The desktop stays SDR, shown at `sdr-brightness` nits. | |
| 206 | 206 | - Fullscreen HDR windows go straight to the display. | |
| 207 | + | - A window that maps with the exact logical size of its output, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized. | |
| 207 | 208 | - Screenshots and captures are SDR (tone-mapped). | |
| 208 | 209 | - If NeferWL crashes, the next compositor may show distorted colours until it sets its own colour pipeline. | |
| 209 | 210 | ||
| @@ -204,6 +204,7 @@ A swipe moves one step at most. Other gestures (pinch, hold, two-finger scroll) | |||||
| 204 | - HDR-aware apps (games, browsers, video players) can show HDR content. Some need HDR turned on in their own settings. X11 apps stay SDR. | 204 | - HDR-aware apps (games, browsers, video players) can show HDR content. Some need HDR turned on in their own settings. X11 apps stay SDR. | ||
| 205 | - The desktop stays SDR, shown at `sdr-brightness` nits. | 205 | - The desktop stays SDR, shown at `sdr-brightness` nits. | ||
| 206 | - Fullscreen HDR windows go straight to the display. | 206 | - Fullscreen HDR windows go straight to the display. | ||
| 207 | + | - A window that maps with the exact logical size of its output, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized. | |||
| 207 | - Screenshots and captures are SDR (tone-mapped). | 208 | - Screenshots and captures are SDR (tone-mapped). | ||
| 208 | - If NeferWL crashes, the next compositor may show distorted colours until it sets its own colour pipeline. | 209 | - If NeferWL crashes, the next compositor may show distorted colours until it sets its own colour pipeline. | ||
| 209 | 210 | ||||
M internal/adapters/wayland/xdg.go +26 -2
| @@ -145,7 +145,8 @@ type window struct { | |||
| 145 | 145 | minW, minH int32 | |
| 146 | 146 | maxW, maxH int32 | |
| 147 | 147 | floating bool | |
| 148 | - | floatW, floatH int // size last sent to core | |
| 148 | + | floatW, floatH int // size last sent to core | |
| 149 | + | sized bool // the first committed size after a map was sent | |
| 149 | 150 | // last is the most recent core configure, resent when decoration mode changes. | |
| 150 | 151 | last ports.ConfigureWindow | |
| 151 | 152 | hasLast bool | |
| @@ -238,6 +239,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||
| 238 | 239 | x.serials = append(x.serials, x.server.serial) | |
| 239 | 240 | } else if buffer && !w.mapped && x.acked { | |
| 240 | 241 | w.mapped = true | |
| 242 | + | w.sized = false | |
| 241 | 243 | w.gen++ | |
| 242 | 244 | // Placement claims are for a new toplevel, not later remaps. | |
| 243 | 245 | // Ordinary commits, popups and remaps need no /proc lookup. | |
| @@ -412,7 +414,19 @@ func (top) SetMinimized(*xdgshell.Toplevel) {} | |||
| 412 | 414 | // afterCommit runs once the commit applied content and geometry: a | |
| 413 | 415 | // floating window keeps the size it draws. | |
| 414 | 416 | func (w *window) afterCommit() { | |
| 415 | - | if !w.mapped || !w.floating { | |
| 417 | + | if !w.mapped { | |
| 418 | + | return | |
| 419 | + | } | |
| 420 | + | if !w.floating { | |
| 421 | + | // Core reads a tile's first size once, to spot a window that maps | |
| 422 | + | // at its output's size without asking for fullscreen. | |
| 423 | + | if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 { | |
| 424 | + | w.sized = true | |
| 425 | + | if !w.xdg.server.outputSized(fw, fh) { | |
| 426 | + | return | |
| 427 | + | } | |
| 428 | + | w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh}) | |
| 429 | + | } | |
| 416 | 430 | return | |
| 417 | 431 | } | |
| 418 | 432 | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | |
| @@ -453,3 +467,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface { | |||
| 453 | 467 | } | |
| 454 | 468 | return nil | |
| 455 | 469 | } | |
| 470 | + | ||
| 471 | + | // outputSized reports whether an output has the logical size w×h. | |
| 472 | + | func (s *Server) outputSized(w, h int) bool { | |
| 473 | + | for _, o := range s.outputs { | |
| 474 | + | if o.place.Width == w && o.place.Height == h { | |
| 475 | + | return true | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | return false | |
| 479 | + | } | |
| @@ -145,7 +145,8 @@ type window struct { | |||||
| 145 | minW, minH int32 | 145 | minW, minH int32 | ||
| 146 | maxW, maxH int32 | 146 | maxW, maxH int32 | ||
| 147 | floating bool | 147 | floating bool | ||
| 148 | - | floatW, floatH int // size last sent to core | 148 | + | floatW, floatH int // size last sent to core |
| 149 | + | sized bool // the first committed size after a map was sent | |||
| 149 | // last is the most recent core configure, resent when decoration mode changes. | 150 | // last is the most recent core configure, resent when decoration mode changes. | ||
| 150 | last ports.ConfigureWindow | 151 | last ports.ConfigureWindow | ||
| 151 | hasLast bool | 152 | hasLast bool | ||
| @@ -238,6 +239,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||||
| 238 | x.serials = append(x.serials, x.server.serial) | 239 | x.serials = append(x.serials, x.server.serial) | ||
| 239 | } else if buffer && !w.mapped && x.acked { | 240 | } else if buffer && !w.mapped && x.acked { | ||
| 240 | w.mapped = true | 241 | w.mapped = true | ||
| 242 | + | w.sized = false | |||
| 241 | w.gen++ | 243 | w.gen++ | ||
| 242 | // Placement claims are for a new toplevel, not later remaps. | 244 | // Placement claims are for a new toplevel, not later remaps. | ||
| 243 | // Ordinary commits, popups and remaps need no /proc lookup. | 245 | // Ordinary commits, popups and remaps need no /proc lookup. | ||
| @@ -412,7 +414,19 @@ func (top) SetMinimized(*xdgshell.Toplevel) {} | |||||
| 412 | // afterCommit runs once the commit applied content and geometry: a | 414 | // afterCommit runs once the commit applied content and geometry: a | ||
| 413 | // floating window keeps the size it draws. | 415 | // floating window keeps the size it draws. | ||
| 414 | func (w *window) afterCommit() { | 416 | func (w *window) afterCommit() { | ||
| 415 | - | if !w.mapped || !w.floating { | 417 | + | if !w.mapped { |
| 418 | + | return | |||
| 419 | + | } | |||
| 420 | + | if !w.floating { | |||
| 421 | + | // Core reads a tile's first size once, to spot a window that maps | |||
| 422 | + | // at its output's size without asking for fullscreen. | |||
| 423 | + | if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 { | |||
| 424 | + | w.sized = true | |||
| 425 | + | if !w.xdg.server.outputSized(fw, fh) { | |||
| 426 | + | return | |||
| 427 | + | } | |||
| 428 | + | w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh}) | |||
| 429 | + | } | |||
| 416 | return | 430 | return | ||
| 417 | } | 431 | } | ||
| 418 | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | 432 | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | ||
| @@ -453,3 +467,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface { | |||||
| 453 | } | 467 | } | ||
| 454 | return nil | 468 | return nil | ||
| 455 | } | 469 | } | ||
| 470 | + | ||||
| 471 | + | // outputSized reports whether an output has the logical size w×h. | |||
| 472 | + | func (s *Server) outputSized(w, h int) bool { | |||
| 473 | + | for _, o := range s.outputs { | |||
| 474 | + | if o.place.Width == w && o.place.Height == h { | |||
| 475 | + | return true | |||
| 476 | + | } | |||
| 477 | + | } | |||
| 478 | + | return false | |||
| 479 | + | } | |||
A internal/core/legacy_fullscreen_internal_test.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // A window whose first committed size is its output's size goes fullscreen, | |
| 10 | + | // focused, exactly as toggle-fullscreen does, and toggling leaves it. | |
| 11 | + | func TestLegacyFullscreenAtOutputSize(t *testing.T) { | |
| 12 | + | c, _, _ := fadeCore(t) | |
| 13 | + | sc := c.cur() | |
| 14 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 15 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 16 | + | o := sc.rect() | |
| 17 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |
| 18 | + | w := sc.mon.Current() | |
| 19 | + | if w.fullscreen != 2 { | |
| 20 | + | t.Fatalf("fullscreen = %d, want 2", w.fullscreen) | |
| 21 | + | } | |
| 22 | + | if id, _ := w.Focused(); id != 2 { | |
| 23 | + | t.Fatalf("focused %d, want 2", id) | |
| 24 | + | } | |
| 25 | + | sc.mon.ToggleFullscreen() | |
| 26 | + | if w.fullscreen != 0 { | |
| 27 | + | t.Fatalf("toggle left fullscreen = %d", w.fullscreen) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestLegacyFullscreenFloatingFixedSize(t *testing.T) { | |
| 32 | + | c, _, _ := fadeCore(t) | |
| 33 | + | sc := c.cur() | |
| 34 | + | c.mapWindow(ports.WindowMapped{ID: 1, Floating: true}) | |
| 35 | + | o := sc.rect() | |
| 36 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |
| 37 | + | if sc.mon.Current().fullscreen != 1 { | |
| 38 | + | t.Fatal("a screen-sized float did not go fullscreen") | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestLegacyFullscreenNoFalsePositive(t *testing.T) { | |
| 43 | + | t.Run("smaller", func(t *testing.T) { | |
| 44 | + | c, _, _ := fadeCore(t) | |
| 45 | + | sc := c.cur() | |
| 46 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 47 | + | o := sc.rect() | |
| 48 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W - 1, Height: o.H}) | |
| 49 | + | if sc.mon.Current().fullscreen != 0 { | |
| 50 | + | t.Fatal("smaller window went fullscreen") | |
| 51 | + | } | |
| 52 | + | }) | |
| 53 | + | t.Run("parent", func(t *testing.T) { | |
| 54 | + | c, _, _ := fadeCore(t) | |
| 55 | + | sc := c.cur() | |
| 56 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 57 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Parent: 1}) | |
| 58 | + | o := sc.rect() | |
| 59 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |
| 60 | + | if sc.mon.Current().fullscreen != 0 { | |
| 61 | + | t.Fatal("dialog went fullscreen") | |
| 62 | + | } | |
| 63 | + | }) | |
| 64 | + | t.Run("later resize", func(t *testing.T) { | |
| 65 | + | c, _, _ := fadeCore(t) | |
| 66 | + | sc := c.cur() | |
| 67 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 68 | + | o := sc.rect() | |
| 69 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 50, Height: 50}) | |
| 70 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |
| 71 | + | if sc.mon.Current().fullscreen != 0 { | |
| 72 | + | t.Fatal("a later resize went fullscreen") | |
| 73 | + | } | |
| 74 | + | }) | |
| 75 | + | } | |
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // A window whose first committed size is its output's size goes fullscreen, | |||
| 10 | + | // focused, exactly as toggle-fullscreen does, and toggling leaves it. | |||
| 11 | + | func TestLegacyFullscreenAtOutputSize(t *testing.T) { | |||
| 12 | + | c, _, _ := fadeCore(t) | |||
| 13 | + | sc := c.cur() | |||
| 14 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 15 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 16 | + | o := sc.rect() | |||
| 17 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |||
| 18 | + | w := sc.mon.Current() | |||
| 19 | + | if w.fullscreen != 2 { | |||
| 20 | + | t.Fatalf("fullscreen = %d, want 2", w.fullscreen) | |||
| 21 | + | } | |||
| 22 | + | if id, _ := w.Focused(); id != 2 { | |||
| 23 | + | t.Fatalf("focused %d, want 2", id) | |||
| 24 | + | } | |||
| 25 | + | sc.mon.ToggleFullscreen() | |||
| 26 | + | if w.fullscreen != 0 { | |||
| 27 | + | t.Fatalf("toggle left fullscreen = %d", w.fullscreen) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestLegacyFullscreenFloatingFixedSize(t *testing.T) { | |||
| 32 | + | c, _, _ := fadeCore(t) | |||
| 33 | + | sc := c.cur() | |||
| 34 | + | c.mapWindow(ports.WindowMapped{ID: 1, Floating: true}) | |||
| 35 | + | o := sc.rect() | |||
| 36 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |||
| 37 | + | if sc.mon.Current().fullscreen != 1 { | |||
| 38 | + | t.Fatal("a screen-sized float did not go fullscreen") | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestLegacyFullscreenNoFalsePositive(t *testing.T) { | |||
| 43 | + | t.Run("smaller", func(t *testing.T) { | |||
| 44 | + | c, _, _ := fadeCore(t) | |||
| 45 | + | sc := c.cur() | |||
| 46 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 47 | + | o := sc.rect() | |||
| 48 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W - 1, Height: o.H}) | |||
| 49 | + | if sc.mon.Current().fullscreen != 0 { | |||
| 50 | + | t.Fatal("smaller window went fullscreen") | |||
| 51 | + | } | |||
| 52 | + | }) | |||
| 53 | + | t.Run("parent", func(t *testing.T) { | |||
| 54 | + | c, _, _ := fadeCore(t) | |||
| 55 | + | sc := c.cur() | |||
| 56 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 57 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Parent: 1}) | |||
| 58 | + | o := sc.rect() | |||
| 59 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |||
| 60 | + | if sc.mon.Current().fullscreen != 0 { | |||
| 61 | + | t.Fatal("dialog went fullscreen") | |||
| 62 | + | } | |||
| 63 | + | }) | |||
| 64 | + | t.Run("later resize", func(t *testing.T) { | |||
| 65 | + | c, _, _ := fadeCore(t) | |||
| 66 | + | sc := c.cur() | |||
| 67 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 68 | + | o := sc.rect() | |||
| 69 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 50, Height: 50}) | |||
| 70 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |||
| 71 | + | if sc.mon.Current().fullscreen != 0 { | |||
| 72 | + | t.Fatal("a later resize went fullscreen") | |||
| 73 | + | } | |||
| 74 | + | }) | |||
| 75 | + | } | |||
M internal/core/transition.go +23 -0
| @@ -518,6 +518,7 @@ func (c *Core) resizeFloating(v ports.WindowResized) { | |||
| 518 | 518 | old = settledRect(sc.mon, v.ID) | |
| 519 | 519 | } | |
| 520 | 520 | w.ResizeFloating(v.ID, v.Width, v.Height) | |
| 521 | + | c.legacyFullscreen(sc, v) | |
| 521 | 522 | if !running { | |
| 522 | 523 | return | |
| 523 | 524 | } | |
| @@ -981,3 +982,25 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot { | |||
| 981 | 982 | } | |
| 982 | 983 | return nil | |
| 983 | 984 | } | |
| 985 | + | ||
| 986 | + | // legacyFullscreen puts a window in the fullscreen state when the first size | |
| 987 | + | // it commits after mapping is exactly its output's logical size: a game in | |
| 988 | + | // borderless-windowed mode (X11 through xwayland-satellite) never asks for | |
| 989 | + | // fullscreen. It is the state toggle-fullscreen sets, and the window is | |
| 990 | + | // not resized. Dialogs (a parent), remaps and later resizes are left alone. | |
| 991 | + | func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) { | |
| 992 | + | if !c.windows.firstSize(v.ID) { | |
| 993 | + | return | |
| 994 | + | } | |
| 995 | + | if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap { | |
| 996 | + | return | |
| 997 | + | } | |
| 998 | + | if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 { | |
| 999 | + | return | |
| 1000 | + | } | |
| 1001 | + | if w, _ := sc.mon.find(v.ID); w != nil { | |
| 1002 | + | w.FocusID(v.ID) | |
| 1003 | + | } | |
| 1004 | + | sc.mon.SetFullscreen(v.ID, true) | |
| 1005 | + | delete(sc.rects, v.ID) | |
| 1006 | + | } | |
| @@ -518,6 +518,7 @@ func (c *Core) resizeFloating(v ports.WindowResized) { | |||||
| 518 | old = settledRect(sc.mon, v.ID) | 518 | old = settledRect(sc.mon, v.ID) | ||
| 519 | } | 519 | } | ||
| 520 | w.ResizeFloating(v.ID, v.Width, v.Height) | 520 | w.ResizeFloating(v.ID, v.Width, v.Height) | ||
| 521 | + | c.legacyFullscreen(sc, v) | |||
| 521 | if !running { | 522 | if !running { | ||
| 522 | return | 523 | return | ||
| 523 | } | 524 | } | ||
| @@ -981,3 +982,25 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot { | |||||
| 981 | } | 982 | } | ||
| 982 | return nil | 983 | return nil | ||
| 983 | } | 984 | } | ||
| 985 | + | ||||
| 986 | + | // legacyFullscreen puts a window in the fullscreen state when the first size | |||
| 987 | + | // it commits after mapping is exactly its output's logical size: a game in | |||
| 988 | + | // borderless-windowed mode (X11 through xwayland-satellite) never asks for | |||
| 989 | + | // fullscreen. It is the state toggle-fullscreen sets, and the window is | |||
| 990 | + | // not resized. Dialogs (a parent), remaps and later resizes are left alone. | |||
| 991 | + | func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) { | |||
| 992 | + | if !c.windows.firstSize(v.ID) { | |||
| 993 | + | return | |||
| 994 | + | } | |||
| 995 | + | if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap { | |||
| 996 | + | return | |||
| 997 | + | } | |||
| 998 | + | if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 { | |||
| 999 | + | return | |||
| 1000 | + | } | |||
| 1001 | + | if w, _ := sc.mon.find(v.ID); w != nil { | |||
| 1002 | + | w.FocusID(v.ID) | |||
| 1003 | + | } | |||
| 1004 | + | sc.mon.SetFullscreen(v.ID, true) | |||
| 1005 | + | delete(sc.rects, v.ID) | |||
| 1006 | + | } | |||
M internal/core/windows.go +14 -1
| @@ -12,6 +12,8 @@ type windowRecord struct { | |||
| 12 | 12 | // client holds the app ID and PID of a mapped window, for State. | |
| 13 | 13 | client ports.WindowMapped | |
| 14 | 14 | mappedAt time.Time | |
| 15 | + | // sized is set once the window's first committed size was seen. | |
| 16 | + | sized bool | |
| 15 | 17 | // region is the input region; nil accepts input everywhere. | |
| 16 | 18 | region *ports.InputRegionChanged | |
| 17 | 19 | // inhibitShortcuts asks to keep compositor binds while focused; | |
| @@ -50,7 +52,18 @@ func (r *windowRegistry) lookup(id WindowID) windowRecord { | |||
| 50 | 52 | ||
| 51 | 53 | func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) { | |
| 52 | 54 | rec := r.at(v.ID) | |
| 53 | - | rec.client, rec.mappedAt = v, now | |
| 55 | + | rec.client, rec.mappedAt, rec.sized = v, now, false | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // firstSize reports whether this is the first committed size of the window | |
| 59 | + | // since it mapped, and records that it was seen. | |
| 60 | + | func (r *windowRegistry) firstSize(id WindowID) bool { | |
| 61 | + | rec := r.records[id] | |
| 62 | + | if rec == nil || rec.sized || rec.client.ID == 0 { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | rec.sized = true | |
| 66 | + | return true | |
| 54 | 67 | } | |
| 55 | 68 | ||
| 56 | 69 | // setAppID updates the app ID of a mapped window only. | |
| @@ -12,6 +12,8 @@ type windowRecord struct { | |||||
| 12 | // client holds the app ID and PID of a mapped window, for State. | 12 | // client holds the app ID and PID of a mapped window, for State. | ||
| 13 | client ports.WindowMapped | 13 | client ports.WindowMapped | ||
| 14 | mappedAt time.Time | 14 | mappedAt time.Time | ||
| 15 | + | // sized is set once the window's first committed size was seen. | |||
| 16 | + | sized bool | |||
| 15 | // region is the input region; nil accepts input everywhere. | 17 | // region is the input region; nil accepts input everywhere. | ||
| 16 | region *ports.InputRegionChanged | 18 | region *ports.InputRegionChanged | ||
| 17 | // inhibitShortcuts asks to keep compositor binds while focused; | 19 | // inhibitShortcuts asks to keep compositor binds while focused; | ||
| @@ -50,7 +52,18 @@ func (r *windowRegistry) lookup(id WindowID) windowRecord { | |||||
| 50 | 52 | ||||
| 51 | func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) { | 53 | func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) { | ||
| 52 | rec := r.at(v.ID) | 54 | rec := r.at(v.ID) | ||
| 53 | - | rec.client, rec.mappedAt = v, now | 55 | + | rec.client, rec.mappedAt, rec.sized = v, now, false |
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // firstSize reports whether this is the first committed size of the window | |||
| 59 | + | // since it mapped, and records that it was seen. | |||
| 60 | + | func (r *windowRegistry) firstSize(id WindowID) bool { | |||
| 61 | + | rec := r.records[id] | |||
| 62 | + | if rec == nil || rec.sized || rec.client.ID == 0 { | |||
| 63 | + | return false | |||
| 64 | + | } | |||
| 65 | + | rec.sized = true | |||
| 66 | + | return true | |||
| 54 | } | 67 | } | ||
| 55 | 68 | ||||
| 56 | // setAppID updates the app ID of a mapped window only. | 69 | // setAppID updates the app ID of a mapped window only. | ||