feat(core): add free floating windows (#82)
toggle-floating lifts a tile into a core-sized float placed by a usable-area fraction; it survives stash, workspace and monitor moves, and move/resize binds nudge it.
- commit
- 6a1f9948a19f178b8c4be1c62d8bbc26de37c7a8
- author
- bnema · · 2026-10-01 16:35 UTC
- committer
- GitHub · · 2026-10-01 16:35 UTC
- parent
- b2a4809
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
8 files · +464 -11
M docs/config.md +8 -0
| @@ -119,6 +119,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 119 | 119 | | `close-window` | Close the focused window | | |
| 120 | 120 | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down` or an activation) leaves fullscreen. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first | | |
| 121 | 121 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | |
| 122 | + | | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window | | |
| 122 | 123 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | |
| 123 | 124 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | |
| 124 | 125 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | |
| @@ -148,6 +149,12 @@ While the stash has the focus, `focus-column-left/right` and the three-finger sw | |||
| 148 | 149 | ||
| 149 | 150 | Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive. | |
| 150 | 151 | ||
| 152 | + | ### Free floating windows | |
| 153 | + | ||
| 154 | + | `toggle-floating` lifts the focused tile out of its column into a free floating window. NeferWL sets its size, and it stays where you put it, kept inside the usable area. Its place is relative to the screen, so it keeps it on another workspace or monitor of any size. | |
| 155 | + | ||
| 156 | + | On a free floating window, `move-column-left/right` and `move-window-up/down` move it by 50 logical pixels, and `set-column-width`/`set-window-height` resize it by a share of the usable width or height. `toggle-window-stash` stashes it and brings it back floating at the same place. `toggle-floating` returns it to its column. | |
| 157 | + | ||
| 151 | 158 | ### Overview | |
| 152 | 159 | ||
| 153 | 160 | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card. | |
| @@ -199,6 +206,7 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+ | |||
| 199 | 206 | | `cmd+shift+f` | `toggle-fullscreen` | | |
| 200 | 207 | | `cmd+s` | `toggle-stash-visible` | | |
| 201 | 208 | | `cmd+shift+s` | `toggle-window-stash` | | |
| 209 | + | | `cmd+shift+space` | `toggle-floating` | | |
| 202 | 210 | | `cmd+o` | `toggle-overview` | | |
| 203 | 211 | | `cmd+q` | `close-window` | | |
| 204 | 212 | | `ctrl+alt+backspace` | `quit` | | |
| @@ -119,6 +119,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 119 | | `close-window` | Close the focused window | | 119 | | `close-window` | Close the focused window | | ||
| 120 | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down` or an activation) leaves fullscreen. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first | | 120 | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down` or an activation) leaves fullscreen. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first | | ||
| 121 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | 121 | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | ||
| 122 | + | | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window | | |||
| 122 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | 123 | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | ||
| 123 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | 124 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | ||
| 124 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | 125 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | ||
| @@ -148,6 +149,12 @@ While the stash has the focus, `focus-column-left/right` and the three-finger sw | |||||
| 148 | 149 | ||||
| 149 | Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive. | 150 | Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive. | ||
| 150 | 151 | ||||
| 152 | + | ### Free floating windows | |||
| 153 | + | ||||
| 154 | + | `toggle-floating` lifts the focused tile out of its column into a free floating window. NeferWL sets its size, and it stays where you put it, kept inside the usable area. Its place is relative to the screen, so it keeps it on another workspace or monitor of any size. | |||
| 155 | + | ||||
| 156 | + | On a free floating window, `move-column-left/right` and `move-window-up/down` move it by 50 logical pixels, and `set-column-width`/`set-window-height` resize it by a share of the usable width or height. `toggle-window-stash` stashes it and brings it back floating at the same place. `toggle-floating` returns it to its column. | |||
| 157 | + | ||||
| 151 | ### Overview | 158 | ### Overview | ||
| 152 | 159 | ||||
| 153 | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card. | 160 | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card. | ||
| @@ -199,6 +206,7 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+ | |||||
| 199 | | `cmd+shift+f` | `toggle-fullscreen` | | 206 | | `cmd+shift+f` | `toggle-fullscreen` | | ||
| 200 | | `cmd+s` | `toggle-stash-visible` | | 207 | | `cmd+s` | `toggle-stash-visible` | | ||
| 201 | | `cmd+shift+s` | `toggle-window-stash` | | 208 | | `cmd+shift+s` | `toggle-window-stash` | | ||
| 209 | + | | `cmd+shift+space` | `toggle-floating` | | |||
| 202 | | `cmd+o` | `toggle-overview` | | 210 | | `cmd+o` | `toggle-overview` | | ||
| 203 | | `cmd+q` | `close-window` | | 211 | | `cmd+q` | `close-window` | | ||
| 204 | | `ctrl+alt+backspace` | `quit` | | 212 | | `ctrl+alt+backspace` | `quit` | | ||
M internal/core/action.go +9 -0
| @@ -166,6 +166,10 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 166 | 166 | // A bind acts on the window on screen: under a covering fullscreen | |
| 167 | 167 | // window, that is the one Focused reports. | |
| 168 | 168 | c.cur().mon.Current().focusCover() | |
| 169 | + | // Before the edge moves below: a free float moves on its screen. | |
| 170 | + | if c.cur().mon.Current().freeAction(a) { | |
| 171 | + | return Effect{} | |
| 172 | + | } | |
| 169 | 173 | if name, ok := NamedArg(a); ok && c.bringNamed(name) { | |
| 170 | 174 | return Effect{} | |
| 171 | 175 | } | |
| @@ -381,6 +385,9 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 381 | 385 | if argv, ok := SpawnArgv(a); ok { | |
| 382 | 386 | return Effect{Spawn: true, Argv: argv} | |
| 383 | 387 | } | |
| 388 | + | if w.freeAction(a) { | |
| 389 | + | return Effect{} | |
| 390 | + | } | |
| 384 | 391 | if axis, pct, ok := ResizeArg(a); ok { | |
| 385 | 392 | if axis == ResizeWidth { | |
| 386 | 393 | w.ResizeColumn(pct) | |
| @@ -418,6 +425,8 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 418 | 425 | w.ToggleWindowStash() | |
| 419 | 426 | case ActionToggleStashVisible: | |
| 420 | 427 | w.ToggleStashVisible() | |
| 428 | + | case ActionToggleFloating: | |
| 429 | + | w.ToggleFloating() | |
| 421 | 430 | case ActionCloseWindow: | |
| 422 | 431 | id, _ := w.Focused() | |
| 423 | 432 | return Effect{Close: id} | |
| @@ -166,6 +166,10 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 166 | // A bind acts on the window on screen: under a covering fullscreen | 166 | // A bind acts on the window on screen: under a covering fullscreen | ||
| 167 | // window, that is the one Focused reports. | 167 | // window, that is the one Focused reports. | ||
| 168 | c.cur().mon.Current().focusCover() | 168 | c.cur().mon.Current().focusCover() | ||
| 169 | + | // Before the edge moves below: a free float moves on its screen. | |||
| 170 | + | if c.cur().mon.Current().freeAction(a) { | |||
| 171 | + | return Effect{} | |||
| 172 | + | } | |||
| 169 | if name, ok := NamedArg(a); ok && c.bringNamed(name) { | 173 | if name, ok := NamedArg(a); ok && c.bringNamed(name) { | ||
| 170 | return Effect{} | 174 | return Effect{} | ||
| 171 | } | 175 | } | ||
| @@ -381,6 +385,9 @@ func (w *Workspace) Apply(a Action) Effect { | |||||
| 381 | if argv, ok := SpawnArgv(a); ok { | 385 | if argv, ok := SpawnArgv(a); ok { | ||
| 382 | return Effect{Spawn: true, Argv: argv} | 386 | return Effect{Spawn: true, Argv: argv} | ||
| 383 | } | 387 | } | ||
| 388 | + | if w.freeAction(a) { | |||
| 389 | + | return Effect{} | |||
| 390 | + | } | |||
| 384 | if axis, pct, ok := ResizeArg(a); ok { | 391 | if axis, pct, ok := ResizeArg(a); ok { | ||
| 385 | if axis == ResizeWidth { | 392 | if axis == ResizeWidth { | ||
| 386 | w.ResizeColumn(pct) | 393 | w.ResizeColumn(pct) | ||
| @@ -418,6 +425,8 @@ func (w *Workspace) Apply(a Action) Effect { | |||||
| 418 | w.ToggleWindowStash() | 425 | w.ToggleWindowStash() | ||
| 419 | case ActionToggleStashVisible: | 426 | case ActionToggleStashVisible: | ||
| 420 | w.ToggleStashVisible() | 427 | w.ToggleStashVisible() | ||
| 428 | + | case ActionToggleFloating: | |||
| 429 | + | w.ToggleFloating() | |||
| 421 | case ActionCloseWindow: | 430 | case ActionCloseWindow: | ||
| 422 | id, _ := w.Focused() | 431 | id, _ := w.Focused() | ||
| 423 | return Effect{Close: id} | 432 | return Effect{Close: id} | ||
A internal/core/free.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | // Free floats: a tile the user floated with toggle-floating (or dragged | |
| 4 | + | // out, drag.go) floats over the columns at a size and centre core owns. | |
| 5 | + | // The centre is a fraction of the usable area, so the window keeps its | |
| 6 | + | // relative place across stash, workspace and monitor moves. | |
| 7 | + | ||
| 8 | + | // freeNudge is how far a keyboard move shifts a free float, logical px. | |
| 9 | + | const freeNudge = 50 | |
| 10 | + | ||
| 11 | + | // freeMinSize is the smallest client size a free float is resized to. | |
| 12 | + | const freeMinSize = 64 | |
| 13 | + | ||
| 14 | + | // ToggleFloating turns the focused tile into a free float at its current | |
| 15 | + | // size, centred; a float (free or native) returns to the columns: a free | |
| 16 | + | // one to its former place, a native one as a new column. A fullscreen or | |
| 17 | + | // stashed window is left alone. | |
| 18 | + | func (w *Workspace) ToggleFloating() { | |
| 19 | + | id, ok := w.Focused() | |
| 20 | + | if !ok || id == w.fullscreen || w.stashIndex(id) >= 0 { | |
| 21 | + | return | |
| 22 | + | } | |
| 23 | + | if i := w.floatIndex(id); i >= 0 { | |
| 24 | + | back := w.Floats[i].back | |
| 25 | + | w.RemoveWindow(id) | |
| 26 | + | w.restore(id, back) | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | r, ok := w.placed(id) | |
| 30 | + | if !ok { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | // Its slot is released: the slot column refills without it. | |
| 34 | + | back := w.tilePlace(id) | |
| 35 | + | back.slot = 0 | |
| 36 | + | w.dropSlotOf(id) | |
| 37 | + | w.RemoveWindow(id) | |
| 38 | + | w.Floats = append(w.Floats, Float{ | |
| 39 | + | ID: id, W: max(r.W-2*w.border, 1), H: max(r.H-2*w.border, 1), | |
| 40 | + | free: true, cx: 0.5, cy: 0.5, back: back, | |
| 41 | + | }) | |
| 42 | + | w.floatFocus, w.stashFocus = true, false | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // placed is the rect of window id in the current layout. | |
| 46 | + | func (w *Workspace) placed(id WindowID) (Rect, bool) { | |
| 47 | + | for _, p := range w.Layout() { | |
| 48 | + | if p.ID == id && !p.Hidden { | |
| 49 | + | return p.Rect, true | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | return Rect{}, false | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // focusedFree returns the index of the focused free float, or -1. | |
| 56 | + | func (w *Workspace) focusedFree() int { | |
| 57 | + | id, ok := w.Focused() | |
| 58 | + | if !ok || !w.floatFocus { | |
| 59 | + | return -1 | |
| 60 | + | } | |
| 61 | + | if i := w.floatIndex(id); i >= 0 && w.Floats[i].free && id != w.fullscreen { | |
| 62 | + | return i | |
| 63 | + | } | |
| 64 | + | return -1 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // freeAction runs a move or resize action on the focused free float and | |
| 68 | + | // reports whether it did: moves shift it by freeNudge, resizes change its | |
| 69 | + | // size by a share of the usable area. | |
| 70 | + | func (w *Workspace) freeAction(a Action) bool { | |
| 71 | + | i := w.focusedFree() | |
| 72 | + | if i < 0 { | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | f := &w.Floats[i] | |
| 76 | + | u := w.Usable | |
| 77 | + | if axis, pct, ok := ResizeArg(a); ok { | |
| 78 | + | if u.W <= 0 || u.H <= 0 { | |
| 79 | + | return true | |
| 80 | + | } | |
| 81 | + | // The size is the client's: the border must fit too. | |
| 82 | + | if axis == ResizeWidth { | |
| 83 | + | f.W = max(min(f.W+u.W*pct/100, u.W-2*w.border), freeMinSize) | |
| 84 | + | } else { | |
| 85 | + | f.H = max(min(f.H+u.H*pct/100, u.H-2*w.border), freeMinSize) | |
| 86 | + | } | |
| 87 | + | w.clampFree(f) | |
| 88 | + | return true | |
| 89 | + | } | |
| 90 | + | dx, dy := 0, 0 | |
| 91 | + | switch a { | |
| 92 | + | case ActionMoveColumnLeft: | |
| 93 | + | dx = -freeNudge | |
| 94 | + | case ActionMoveColumnRight: | |
| 95 | + | dx = freeNudge | |
| 96 | + | case ActionMoveWindowUp: | |
| 97 | + | dy = -freeNudge | |
| 98 | + | case ActionMoveWindowDown: | |
| 99 | + | dy = freeNudge | |
| 100 | + | default: | |
| 101 | + | return false | |
| 102 | + | } | |
| 103 | + | if u.W <= 0 || u.H <= 0 { | |
| 104 | + | return true | |
| 105 | + | } | |
| 106 | + | w.moveFree(f, w.floatRect(*f), dx, dy) | |
| 107 | + | return true | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // moveFree puts free float f at rect r shifted by dx, dy, kept inside | |
| 111 | + | // the usable area. | |
| 112 | + | func (w *Workspace) moveFree(f *Float, r Rect, dx, dy int) { | |
| 113 | + | u := w.Usable | |
| 114 | + | x := min(max(r.X+dx, u.X), u.X+u.W-r.W) | |
| 115 | + | y := min(max(r.Y+dy, u.Y), u.Y+u.H-r.H) | |
| 116 | + | f.cx = float64(x-u.X+r.W/2) / float64(u.W) | |
| 117 | + | f.cy = float64(y-u.Y+r.H/2) / float64(u.H) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // clampFree keeps the centre of f where its rect stays inside the area. | |
| 121 | + | func (w *Workspace) clampFree(f *Float) { | |
| 122 | + | w.moveFree(f, w.floatRect(*f), 0, 0) | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | // Free floats: a tile the user floated with toggle-floating (or dragged | |||
| 4 | + | // out, drag.go) floats over the columns at a size and centre core owns. | |||
| 5 | + | // The centre is a fraction of the usable area, so the window keeps its | |||
| 6 | + | // relative place across stash, workspace and monitor moves. | |||
| 7 | + | ||||
| 8 | + | // freeNudge is how far a keyboard move shifts a free float, logical px. | |||
| 9 | + | const freeNudge = 50 | |||
| 10 | + | ||||
| 11 | + | // freeMinSize is the smallest client size a free float is resized to. | |||
| 12 | + | const freeMinSize = 64 | |||
| 13 | + | ||||
| 14 | + | // ToggleFloating turns the focused tile into a free float at its current | |||
| 15 | + | // size, centred; a float (free or native) returns to the columns: a free | |||
| 16 | + | // one to its former place, a native one as a new column. A fullscreen or | |||
| 17 | + | // stashed window is left alone. | |||
| 18 | + | func (w *Workspace) ToggleFloating() { | |||
| 19 | + | id, ok := w.Focused() | |||
| 20 | + | if !ok || id == w.fullscreen || w.stashIndex(id) >= 0 { | |||
| 21 | + | return | |||
| 22 | + | } | |||
| 23 | + | if i := w.floatIndex(id); i >= 0 { | |||
| 24 | + | back := w.Floats[i].back | |||
| 25 | + | w.RemoveWindow(id) | |||
| 26 | + | w.restore(id, back) | |||
| 27 | + | return | |||
| 28 | + | } | |||
| 29 | + | r, ok := w.placed(id) | |||
| 30 | + | if !ok { | |||
| 31 | + | return | |||
| 32 | + | } | |||
| 33 | + | // Its slot is released: the slot column refills without it. | |||
| 34 | + | back := w.tilePlace(id) | |||
| 35 | + | back.slot = 0 | |||
| 36 | + | w.dropSlotOf(id) | |||
| 37 | + | w.RemoveWindow(id) | |||
| 38 | + | w.Floats = append(w.Floats, Float{ | |||
| 39 | + | ID: id, W: max(r.W-2*w.border, 1), H: max(r.H-2*w.border, 1), | |||
| 40 | + | free: true, cx: 0.5, cy: 0.5, back: back, | |||
| 41 | + | }) | |||
| 42 | + | w.floatFocus, w.stashFocus = true, false | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // placed is the rect of window id in the current layout. | |||
| 46 | + | func (w *Workspace) placed(id WindowID) (Rect, bool) { | |||
| 47 | + | for _, p := range w.Layout() { | |||
| 48 | + | if p.ID == id && !p.Hidden { | |||
| 49 | + | return p.Rect, true | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | return Rect{}, false | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // focusedFree returns the index of the focused free float, or -1. | |||
| 56 | + | func (w *Workspace) focusedFree() int { | |||
| 57 | + | id, ok := w.Focused() | |||
| 58 | + | if !ok || !w.floatFocus { | |||
| 59 | + | return -1 | |||
| 60 | + | } | |||
| 61 | + | if i := w.floatIndex(id); i >= 0 && w.Floats[i].free && id != w.fullscreen { | |||
| 62 | + | return i | |||
| 63 | + | } | |||
| 64 | + | return -1 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // freeAction runs a move or resize action on the focused free float and | |||
| 68 | + | // reports whether it did: moves shift it by freeNudge, resizes change its | |||
| 69 | + | // size by a share of the usable area. | |||
| 70 | + | func (w *Workspace) freeAction(a Action) bool { | |||
| 71 | + | i := w.focusedFree() | |||
| 72 | + | if i < 0 { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | f := &w.Floats[i] | |||
| 76 | + | u := w.Usable | |||
| 77 | + | if axis, pct, ok := ResizeArg(a); ok { | |||
| 78 | + | if u.W <= 0 || u.H <= 0 { | |||
| 79 | + | return true | |||
| 80 | + | } | |||
| 81 | + | // The size is the client's: the border must fit too. | |||
| 82 | + | if axis == ResizeWidth { | |||
| 83 | + | f.W = max(min(f.W+u.W*pct/100, u.W-2*w.border), freeMinSize) | |||
| 84 | + | } else { | |||
| 85 | + | f.H = max(min(f.H+u.H*pct/100, u.H-2*w.border), freeMinSize) | |||
| 86 | + | } | |||
| 87 | + | w.clampFree(f) | |||
| 88 | + | return true | |||
| 89 | + | } | |||
| 90 | + | dx, dy := 0, 0 | |||
| 91 | + | switch a { | |||
| 92 | + | case ActionMoveColumnLeft: | |||
| 93 | + | dx = -freeNudge | |||
| 94 | + | case ActionMoveColumnRight: | |||
| 95 | + | dx = freeNudge | |||
| 96 | + | case ActionMoveWindowUp: | |||
| 97 | + | dy = -freeNudge | |||
| 98 | + | case ActionMoveWindowDown: | |||
| 99 | + | dy = freeNudge | |||
| 100 | + | default: | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | if u.W <= 0 || u.H <= 0 { | |||
| 104 | + | return true | |||
| 105 | + | } | |||
| 106 | + | w.moveFree(f, w.floatRect(*f), dx, dy) | |||
| 107 | + | return true | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // moveFree puts free float f at rect r shifted by dx, dy, kept inside | |||
| 111 | + | // the usable area. | |||
| 112 | + | func (w *Workspace) moveFree(f *Float, r Rect, dx, dy int) { | |||
| 113 | + | u := w.Usable | |||
| 114 | + | x := min(max(r.X+dx, u.X), u.X+u.W-r.W) | |||
| 115 | + | y := min(max(r.Y+dy, u.Y), u.Y+u.H-r.H) | |||
| 116 | + | f.cx = float64(x-u.X+r.W/2) / float64(u.W) | |||
| 117 | + | f.cy = float64(y-u.Y+r.H/2) / float64(u.H) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // clampFree keeps the centre of f where its rect stays inside the area. | |||
| 121 | + | func (w *Workspace) clampFree(f *Float) { | |||
| 122 | + | w.moveFree(f, w.floatRect(*f), 0, 0) | |||
| 123 | + | } | |||
A internal/core/free_core_test.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func windowIn(set []ports.Scene, output string, id ports.WindowID) (ports.SceneWindow, bool) { | |
| 10 | + | for _, s := range set { | |
| 11 | + | if s.Output != output { | |
| 12 | + | continue | |
| 13 | + | } | |
| 14 | + | for _, w := range s.Windows { | |
| 15 | + | if w.ID == id && !w.Hidden { | |
| 16 | + | return w, true | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | return ports.SceneWindow{}, false | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) { | |
| 24 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right) | |
| 25 | + | r.mapWindow(t, 1) | |
| 26 | + | r.mapWindow(t, 2) | |
| 27 | + | r.key(t, "space", ports.ModAlt|ports.ModShift) | |
| 28 | + | // Nudge left twice: 100 px left of centre on a 200 px output stops at | |
| 29 | + | // the edge. | |
| 30 | + | r.key(t, "Left", ports.ModAlt|ports.ModShift) | |
| 31 | + | set := r.key(t, "Left", ports.ModAlt|ports.ModShift) | |
| 32 | + | before, ok := windowIn(set, "DP-1", 2) | |
| 33 | + | if !ok || !before.Floating || before.Rect.X != 0 { | |
| 34 | + | t.Fatalf("%+v", before) | |
| 35 | + | } | |
| 36 | + | // The workspace moves to the 400x200 output: the centre keeps its | |
| 37 | + | // fractions of the usable area (1/4, 1/2), the size stays. | |
| 38 | + | set = r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |
| 39 | + | after, ok := windowIn(set, "DP-2", 2) | |
| 40 | + | if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 { | |
| 41 | + | t.Fatalf("%+v", after) | |
| 42 | + | } | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func windowIn(set []ports.Scene, output string, id ports.WindowID) (ports.SceneWindow, bool) { | |||
| 10 | + | for _, s := range set { | |||
| 11 | + | if s.Output != output { | |||
| 12 | + | continue | |||
| 13 | + | } | |||
| 14 | + | for _, w := range s.Windows { | |||
| 15 | + | if w.ID == id && !w.Hidden { | |||
| 16 | + | return w, true | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | return ports.SceneWindow{}, false | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) { | |||
| 24 | + | r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right) | |||
| 25 | + | r.mapWindow(t, 1) | |||
| 26 | + | r.mapWindow(t, 2) | |||
| 27 | + | r.key(t, "space", ports.ModAlt|ports.ModShift) | |||
| 28 | + | // Nudge left twice: 100 px left of centre on a 200 px output stops at | |||
| 29 | + | // the edge. | |||
| 30 | + | r.key(t, "Left", ports.ModAlt|ports.ModShift) | |||
| 31 | + | set := r.key(t, "Left", ports.ModAlt|ports.ModShift) | |||
| 32 | + | before, ok := windowIn(set, "DP-1", 2) | |||
| 33 | + | if !ok || !before.Floating || before.Rect.X != 0 { | |||
| 34 | + | t.Fatalf("%+v", before) | |||
| 35 | + | } | |||
| 36 | + | // The workspace moves to the 400x200 output: the centre keeps its | |||
| 37 | + | // fractions of the usable area (1/4, 1/2), the size stays. | |||
| 38 | + | set = r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift) | |||
| 39 | + | after, ok := windowIn(set, "DP-2", 2) | |||
| 40 | + | if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 { | |||
| 41 | + | t.Fatalf("%+v", after) | |||
| 42 | + | } | |||
| 43 | + | } | |||
A internal/core/free_test.go +225 -0
| @@ -0,0 +1,225 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func freeWorkspace() *Workspace { | |
| 9 | + | w := workspace() | |
| 10 | + | w.SetOutput(1000, 800) | |
| 11 | + | w.SetGaps(10) | |
| 12 | + | return w | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func TestFreeFloatRect(t *testing.T) { | |
| 16 | + | for _, size := range [][2]int{{1000, 800}, {2000, 1200}} { | |
| 17 | + | w := freeWorkspace() | |
| 18 | + | w.SetOutput(size[0], size[1]) | |
| 19 | + | f := Float{ID: 1, W: 200, H: 100, free: true, cx: 0.25, cy: 0.75} | |
| 20 | + | r := w.floatRect(f) | |
| 21 | + | u := w.Usable | |
| 22 | + | if r.X+r.W/2 != u.X+u.W/4 || r.Y+r.H/2 != u.Y+u.H*3/4 || r.W != 200 || r.H != 100 { | |
| 23 | + | t.Fatal(size, r) | |
| 24 | + | } | |
| 25 | + | // Clamped inside the usable area at the edges. | |
| 26 | + | f.cx, f.cy = 0, 1 | |
| 27 | + | r = w.floatRect(f) | |
| 28 | + | if r.X != u.X || r.Y+r.H != u.Y+u.H { | |
| 29 | + | t.Fatal(size, r) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestToggleFloatingRoundTrip(t *testing.T) { | |
| 35 | + | w := freeWorkspace() | |
| 36 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Width: Width{Num: 1, Den: 2}}} | |
| 37 | + | w.Focus = 1 | |
| 38 | + | r, _ := w.placed(3) | |
| 39 | + | w.Apply(ActionToggleFloating) | |
| 40 | + | i := w.floatIndex(3) | |
| 41 | + | if i < 0 || !w.Floats[i].free || !w.floatFocus || w.Floats[i].W != r.W || w.Floats[i].H != r.H { | |
| 42 | + | t.Fatal(w.Floats, r) | |
| 43 | + | } | |
| 44 | + | if got := w.floatRect(w.Floats[i]); got.X+got.W/2 != w.Usable.X+w.Usable.W/2 { | |
| 45 | + | t.Fatal(got) | |
| 46 | + | } | |
| 47 | + | if !w.imposedFloat(3) || len(w.Columns[1].Windows) != 1 { | |
| 48 | + | t.Fatal(w.Columns) | |
| 49 | + | } | |
| 50 | + | // Client reports do not change a free float's size. | |
| 51 | + | w.ResizeFloating(3, 10, 10) | |
| 52 | + | if w.Floats[i].W != r.W { | |
| 53 | + | t.Fatal(w.Floats[i]) | |
| 54 | + | } | |
| 55 | + | w.Apply(ActionToggleFloating) | |
| 56 | + | if w.floatIndex(3) >= 0 || !slices.Equal(w.Columns[1].Windows, []WindowID{2, 3}) || w.Columns[1].Focus != 1 || w.Focus != 1 { | |
| 57 | + | t.Fatal(w.Columns) | |
| 58 | + | } | |
| 59 | + | // A lone column comes back at its width and index. | |
| 60 | + | w.Focus = 1 | |
| 61 | + | w.Columns[1] = Column{Windows: []WindowID{2}, Width: Width{Num: 1, Den: 2}} | |
| 62 | + | w.Columns = append(w.Columns, Column{Windows: []WindowID{3}}) | |
| 63 | + | w.Apply(ActionToggleFloating) | |
| 64 | + | w.Apply(ActionToggleFloating) | |
| 65 | + | if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Width != (Width{Num: 1, Den: 2}) || w.Focus != 1 { | |
| 66 | + | t.Fatal(w.Columns, w.Focus) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestToggleFloatingNativeAndNoops(t *testing.T) { | |
| 71 | + | w := freeWorkspace() | |
| 72 | + | w.AddWindow(1) | |
| 73 | + | w.AddFloating(2, 100, 100) | |
| 74 | + | w.Apply(ActionToggleFloating) | |
| 75 | + | if w.floatIndex(2) >= 0 || len(w.Columns) != 2 { | |
| 76 | + | t.Fatal(w.Columns, w.Floats) | |
| 77 | + | } | |
| 78 | + | // Stashed: no-op. | |
| 79 | + | w.Apply(ActionToggleWindowStash) | |
| 80 | + | id, _ := w.Focused() | |
| 81 | + | w.Apply(ActionToggleFloating) | |
| 82 | + | if w.stashIndex(id) < 0 { | |
| 83 | + | t.Fatal(w.Stash) | |
| 84 | + | } | |
| 85 | + | // Fullscreen: no-op. | |
| 86 | + | w = freeWorkspace() | |
| 87 | + | w.AddWindow(1) | |
| 88 | + | w.SetFullscreen(1, true) | |
| 89 | + | w.Apply(ActionToggleFloating) | |
| 90 | + | if len(w.Columns) != 1 || len(w.Floats) != 0 { | |
| 91 | + | t.Fatal(w.Columns) | |
| 92 | + | } | |
| 93 | + | // Fixed overflow: the fullscreen window covers the others and keeps | |
| 94 | + | // the focus, so nothing floats. | |
| 95 | + | w = freeWorkspace() | |
| 96 | + | w.Overflow = OverflowFixed | |
| 97 | + | w.AddWindow(1) | |
| 98 | + | w.AddWindow(2) | |
| 99 | + | w.SetFullscreen(2, true) | |
| 100 | + | w.Apply(ActionToggleFloating) | |
| 101 | + | if len(w.Columns) != 2 || len(w.Floats) != 0 { | |
| 102 | + | t.Fatal(w.Columns, w.Floats) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestFreeFloatStash(t *testing.T) { | |
| 107 | + | w := freeWorkspace() | |
| 108 | + | w.AddWindow(1) | |
| 109 | + | w.AddWindow(2) | |
| 110 | + | w.Apply(ActionToggleFloating) | |
| 111 | + | i := w.floatIndex(2) | |
| 112 | + | w.Floats[i].cx, w.Floats[i].cy = 0.3, 0.6 | |
| 113 | + | want := w.Floats[i] | |
| 114 | + | w.Apply(ActionToggleWindowStash) | |
| 115 | + | if w.stashIndex(2) < 0 || w.floatIndex(2) >= 0 { | |
| 116 | + | t.Fatal(w.Stash, w.Floats) | |
| 117 | + | } | |
| 118 | + | w.Apply(ActionToggleWindowStash) | |
| 119 | + | if i := w.floatIndex(2); i < 0 || w.Floats[i].cx != 0.3 || w.Floats[i].cy != 0.6 || w.Floats[i].W != want.W || !w.floatFocus { | |
| 120 | + | t.Fatal(w.Floats) | |
| 121 | + | } | |
| 122 | + | // It still returns to its column. | |
| 123 | + | w.Apply(ActionToggleFloating) | |
| 124 | + | if len(w.Columns) != 2 || w.Columns[1].Windows[0] != 2 { | |
| 125 | + | t.Fatal(w.Columns) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestFreeFloatMovesAcrossWorkspaces(t *testing.T) { | |
| 130 | + | m := monitor() | |
| 131 | + | m.SetOutput(1000, 800) | |
| 132 | + | m.AddWindow(1) | |
| 133 | + | m.AddWindow(2) | |
| 134 | + | m.Apply(ActionToggleFloating) | |
| 135 | + | w := m.Current() | |
| 136 | + | i := w.floatIndex(2) | |
| 137 | + | w.Floats[i].cx, w.Floats[i].cy = 0.2, 0.8 | |
| 138 | + | m.MoveToWorkspace(1, false) | |
| 139 | + | to := m.Workspaces[1] | |
| 140 | + | if i := to.floatIndex(2); i < 0 || !to.Floats[i].free || to.Floats[i].cx != 0.2 || to.Floats[i].cy != 0.8 { | |
| 141 | + | t.Fatal(to.Floats) | |
| 142 | + | } | |
| 143 | + | // Stashed there, moved back, unstashed: still free at its place. | |
| 144 | + | m.FocusNumber(2) | |
| 145 | + | m.Apply(ActionToggleWindowStash) | |
| 146 | + | m.MoveToWorkspace(0, false) | |
| 147 | + | m.FocusNumber(1) | |
| 148 | + | m.Apply(ActionToggleWindowStash) | |
| 149 | + | w = m.Current() | |
| 150 | + | if i := w.floatIndex(2); i < 0 || !w.Floats[i].free || w.Floats[i].cx != 0.2 { | |
| 151 | + | t.Fatal(w.Floats, w.Stash) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestFreeFloatReturnsAfterWorkspaceMove(t *testing.T) { | |
| 156 | + | m := monitor() | |
| 157 | + | m.SetOutput(1000, 800) | |
| 158 | + | m.AddWindow(1) | |
| 159 | + | m.AddWindow(2) | |
| 160 | + | m.Apply(ActionMaximizeColumn) | |
| 161 | + | m.Apply(ActionToggleFloating) | |
| 162 | + | m.MoveToWorkspace(1, false) | |
| 163 | + | m.FocusNumber(2) | |
| 164 | + | m.Current().FocusID(2) | |
| 165 | + | m.Apply(ActionToggleFloating) | |
| 166 | + | w := m.Current() | |
| 167 | + | if len(w.Columns) != 1 || w.Columns[0].Windows[0] != 2 || w.Columns[0].FullWidth { | |
| 168 | + | t.Fatal(w.Columns) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestToggleFloatingReleasesSlot(t *testing.T) { | |
| 173 | + | w := freeWorkspace() | |
| 174 | + | w.Columns = []Column{{Windows: []WindowID{1}, Slot: 7}} | |
| 175 | + | w.Apply(ActionToggleFloating) | |
| 176 | + | w.Apply(ActionToggleFloating) | |
| 177 | + | if len(w.Columns) != 1 || w.Columns[0].Slot != 0 { | |
| 178 | + | t.Fatal(w.Columns) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestFreeFloatNoUsableArea(t *testing.T) { | |
| 183 | + | w := freeWorkspace() | |
| 184 | + | w.AddWindow(1) | |
| 185 | + | w.Apply(ActionToggleFloating) | |
| 186 | + | w.SetUsable(Rect{}) | |
| 187 | + | if fx := w.Apply(ActionCloseWindow); fx.Close != 1 { | |
| 188 | + | t.Fatal(fx) | |
| 189 | + | } | |
| 190 | + | w.Apply(ActionMoveColumnLeft) | |
| 191 | + | w.Apply("set-column-width +10%") | |
| 192 | + | if w.floatIndex(1) < 0 { | |
| 193 | + | t.Fatal(w.Floats) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestFreeFloatKeyboard(t *testing.T) { | |
| 198 | + | w := freeWorkspace() | |
| 199 | + | w.AddWindow(1) | |
| 200 | + | w.AddWindow(2) | |
| 201 | + | w.Apply(ActionToggleFloating) | |
| 202 | + | i := w.floatIndex(2) | |
| 203 | + | w.Floats[i].W, w.Floats[i].H = 200, 100 | |
| 204 | + | r := w.floatRect(w.Floats[i]) | |
| 205 | + | w.Apply(ActionMoveColumnLeft) | |
| 206 | + | w.Apply(ActionMoveWindowDown) | |
| 207 | + | if got := w.floatRect(w.Floats[i]); got.X != r.X-freeNudge || got.Y != r.Y+freeNudge { | |
| 208 | + | t.Fatal(got, r) | |
| 209 | + | } | |
| 210 | + | for range 50 { | |
| 211 | + | w.Apply(ActionMoveColumnLeft) | |
| 212 | + | } | |
| 213 | + | if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X { | |
| 214 | + | t.Fatal(got) | |
| 215 | + | } | |
| 216 | + | w.Apply("set-column-width +10%") | |
| 217 | + | w.Apply("set-window-height -10%") | |
| 218 | + | if w.Floats[i].W != 200+w.Usable.W/10 || w.Floats[i].H != freeMinSize { | |
| 219 | + | t.Fatal(w.Floats[i]) | |
| 220 | + | } | |
| 221 | + | // The columns did not change. | |
| 222 | + | if len(w.Columns) != 1 || w.Columns[0].Width != (Width{}) { | |
| 223 | + | t.Fatal(w.Columns) | |
| 224 | + | } | |
| 225 | + | } | |
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func freeWorkspace() *Workspace { | |||
| 9 | + | w := workspace() | |||
| 10 | + | w.SetOutput(1000, 800) | |||
| 11 | + | w.SetGaps(10) | |||
| 12 | + | return w | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func TestFreeFloatRect(t *testing.T) { | |||
| 16 | + | for _, size := range [][2]int{{1000, 800}, {2000, 1200}} { | |||
| 17 | + | w := freeWorkspace() | |||
| 18 | + | w.SetOutput(size[0], size[1]) | |||
| 19 | + | f := Float{ID: 1, W: 200, H: 100, free: true, cx: 0.25, cy: 0.75} | |||
| 20 | + | r := w.floatRect(f) | |||
| 21 | + | u := w.Usable | |||
| 22 | + | if r.X+r.W/2 != u.X+u.W/4 || r.Y+r.H/2 != u.Y+u.H*3/4 || r.W != 200 || r.H != 100 { | |||
| 23 | + | t.Fatal(size, r) | |||
| 24 | + | } | |||
| 25 | + | // Clamped inside the usable area at the edges. | |||
| 26 | + | f.cx, f.cy = 0, 1 | |||
| 27 | + | r = w.floatRect(f) | |||
| 28 | + | if r.X != u.X || r.Y+r.H != u.Y+u.H { | |||
| 29 | + | t.Fatal(size, r) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestToggleFloatingRoundTrip(t *testing.T) { | |||
| 35 | + | w := freeWorkspace() | |||
| 36 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Width: Width{Num: 1, Den: 2}}} | |||
| 37 | + | w.Focus = 1 | |||
| 38 | + | r, _ := w.placed(3) | |||
| 39 | + | w.Apply(ActionToggleFloating) | |||
| 40 | + | i := w.floatIndex(3) | |||
| 41 | + | if i < 0 || !w.Floats[i].free || !w.floatFocus || w.Floats[i].W != r.W || w.Floats[i].H != r.H { | |||
| 42 | + | t.Fatal(w.Floats, r) | |||
| 43 | + | } | |||
| 44 | + | if got := w.floatRect(w.Floats[i]); got.X+got.W/2 != w.Usable.X+w.Usable.W/2 { | |||
| 45 | + | t.Fatal(got) | |||
| 46 | + | } | |||
| 47 | + | if !w.imposedFloat(3) || len(w.Columns[1].Windows) != 1 { | |||
| 48 | + | t.Fatal(w.Columns) | |||
| 49 | + | } | |||
| 50 | + | // Client reports do not change a free float's size. | |||
| 51 | + | w.ResizeFloating(3, 10, 10) | |||
| 52 | + | if w.Floats[i].W != r.W { | |||
| 53 | + | t.Fatal(w.Floats[i]) | |||
| 54 | + | } | |||
| 55 | + | w.Apply(ActionToggleFloating) | |||
| 56 | + | if w.floatIndex(3) >= 0 || !slices.Equal(w.Columns[1].Windows, []WindowID{2, 3}) || w.Columns[1].Focus != 1 || w.Focus != 1 { | |||
| 57 | + | t.Fatal(w.Columns) | |||
| 58 | + | } | |||
| 59 | + | // A lone column comes back at its width and index. | |||
| 60 | + | w.Focus = 1 | |||
| 61 | + | w.Columns[1] = Column{Windows: []WindowID{2}, Width: Width{Num: 1, Den: 2}} | |||
| 62 | + | w.Columns = append(w.Columns, Column{Windows: []WindowID{3}}) | |||
| 63 | + | w.Apply(ActionToggleFloating) | |||
| 64 | + | w.Apply(ActionToggleFloating) | |||
| 65 | + | if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Width != (Width{Num: 1, Den: 2}) || w.Focus != 1 { | |||
| 66 | + | t.Fatal(w.Columns, w.Focus) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestToggleFloatingNativeAndNoops(t *testing.T) { | |||
| 71 | + | w := freeWorkspace() | |||
| 72 | + | w.AddWindow(1) | |||
| 73 | + | w.AddFloating(2, 100, 100) | |||
| 74 | + | w.Apply(ActionToggleFloating) | |||
| 75 | + | if w.floatIndex(2) >= 0 || len(w.Columns) != 2 { | |||
| 76 | + | t.Fatal(w.Columns, w.Floats) | |||
| 77 | + | } | |||
| 78 | + | // Stashed: no-op. | |||
| 79 | + | w.Apply(ActionToggleWindowStash) | |||
| 80 | + | id, _ := w.Focused() | |||
| 81 | + | w.Apply(ActionToggleFloating) | |||
| 82 | + | if w.stashIndex(id) < 0 { | |||
| 83 | + | t.Fatal(w.Stash) | |||
| 84 | + | } | |||
| 85 | + | // Fullscreen: no-op. | |||
| 86 | + | w = freeWorkspace() | |||
| 87 | + | w.AddWindow(1) | |||
| 88 | + | w.SetFullscreen(1, true) | |||
| 89 | + | w.Apply(ActionToggleFloating) | |||
| 90 | + | if len(w.Columns) != 1 || len(w.Floats) != 0 { | |||
| 91 | + | t.Fatal(w.Columns) | |||
| 92 | + | } | |||
| 93 | + | // Fixed overflow: the fullscreen window covers the others and keeps | |||
| 94 | + | // the focus, so nothing floats. | |||
| 95 | + | w = freeWorkspace() | |||
| 96 | + | w.Overflow = OverflowFixed | |||
| 97 | + | w.AddWindow(1) | |||
| 98 | + | w.AddWindow(2) | |||
| 99 | + | w.SetFullscreen(2, true) | |||
| 100 | + | w.Apply(ActionToggleFloating) | |||
| 101 | + | if len(w.Columns) != 2 || len(w.Floats) != 0 { | |||
| 102 | + | t.Fatal(w.Columns, w.Floats) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestFreeFloatStash(t *testing.T) { | |||
| 107 | + | w := freeWorkspace() | |||
| 108 | + | w.AddWindow(1) | |||
| 109 | + | w.AddWindow(2) | |||
| 110 | + | w.Apply(ActionToggleFloating) | |||
| 111 | + | i := w.floatIndex(2) | |||
| 112 | + | w.Floats[i].cx, w.Floats[i].cy = 0.3, 0.6 | |||
| 113 | + | want := w.Floats[i] | |||
| 114 | + | w.Apply(ActionToggleWindowStash) | |||
| 115 | + | if w.stashIndex(2) < 0 || w.floatIndex(2) >= 0 { | |||
| 116 | + | t.Fatal(w.Stash, w.Floats) | |||
| 117 | + | } | |||
| 118 | + | w.Apply(ActionToggleWindowStash) | |||
| 119 | + | if i := w.floatIndex(2); i < 0 || w.Floats[i].cx != 0.3 || w.Floats[i].cy != 0.6 || w.Floats[i].W != want.W || !w.floatFocus { | |||
| 120 | + | t.Fatal(w.Floats) | |||
| 121 | + | } | |||
| 122 | + | // It still returns to its column. | |||
| 123 | + | w.Apply(ActionToggleFloating) | |||
| 124 | + | if len(w.Columns) != 2 || w.Columns[1].Windows[0] != 2 { | |||
| 125 | + | t.Fatal(w.Columns) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestFreeFloatMovesAcrossWorkspaces(t *testing.T) { | |||
| 130 | + | m := monitor() | |||
| 131 | + | m.SetOutput(1000, 800) | |||
| 132 | + | m.AddWindow(1) | |||
| 133 | + | m.AddWindow(2) | |||
| 134 | + | m.Apply(ActionToggleFloating) | |||
| 135 | + | w := m.Current() | |||
| 136 | + | i := w.floatIndex(2) | |||
| 137 | + | w.Floats[i].cx, w.Floats[i].cy = 0.2, 0.8 | |||
| 138 | + | m.MoveToWorkspace(1, false) | |||
| 139 | + | to := m.Workspaces[1] | |||
| 140 | + | if i := to.floatIndex(2); i < 0 || !to.Floats[i].free || to.Floats[i].cx != 0.2 || to.Floats[i].cy != 0.8 { | |||
| 141 | + | t.Fatal(to.Floats) | |||
| 142 | + | } | |||
| 143 | + | // Stashed there, moved back, unstashed: still free at its place. | |||
| 144 | + | m.FocusNumber(2) | |||
| 145 | + | m.Apply(ActionToggleWindowStash) | |||
| 146 | + | m.MoveToWorkspace(0, false) | |||
| 147 | + | m.FocusNumber(1) | |||
| 148 | + | m.Apply(ActionToggleWindowStash) | |||
| 149 | + | w = m.Current() | |||
| 150 | + | if i := w.floatIndex(2); i < 0 || !w.Floats[i].free || w.Floats[i].cx != 0.2 { | |||
| 151 | + | t.Fatal(w.Floats, w.Stash) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestFreeFloatReturnsAfterWorkspaceMove(t *testing.T) { | |||
| 156 | + | m := monitor() | |||
| 157 | + | m.SetOutput(1000, 800) | |||
| 158 | + | m.AddWindow(1) | |||
| 159 | + | m.AddWindow(2) | |||
| 160 | + | m.Apply(ActionMaximizeColumn) | |||
| 161 | + | m.Apply(ActionToggleFloating) | |||
| 162 | + | m.MoveToWorkspace(1, false) | |||
| 163 | + | m.FocusNumber(2) | |||
| 164 | + | m.Current().FocusID(2) | |||
| 165 | + | m.Apply(ActionToggleFloating) | |||
| 166 | + | w := m.Current() | |||
| 167 | + | if len(w.Columns) != 1 || w.Columns[0].Windows[0] != 2 || w.Columns[0].FullWidth { | |||
| 168 | + | t.Fatal(w.Columns) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestToggleFloatingReleasesSlot(t *testing.T) { | |||
| 173 | + | w := freeWorkspace() | |||
| 174 | + | w.Columns = []Column{{Windows: []WindowID{1}, Slot: 7}} | |||
| 175 | + | w.Apply(ActionToggleFloating) | |||
| 176 | + | w.Apply(ActionToggleFloating) | |||
| 177 | + | if len(w.Columns) != 1 || w.Columns[0].Slot != 0 { | |||
| 178 | + | t.Fatal(w.Columns) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestFreeFloatNoUsableArea(t *testing.T) { | |||
| 183 | + | w := freeWorkspace() | |||
| 184 | + | w.AddWindow(1) | |||
| 185 | + | w.Apply(ActionToggleFloating) | |||
| 186 | + | w.SetUsable(Rect{}) | |||
| 187 | + | if fx := w.Apply(ActionCloseWindow); fx.Close != 1 { | |||
| 188 | + | t.Fatal(fx) | |||
| 189 | + | } | |||
| 190 | + | w.Apply(ActionMoveColumnLeft) | |||
| 191 | + | w.Apply("set-column-width +10%") | |||
| 192 | + | if w.floatIndex(1) < 0 { | |||
| 193 | + | t.Fatal(w.Floats) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestFreeFloatKeyboard(t *testing.T) { | |||
| 198 | + | w := freeWorkspace() | |||
| 199 | + | w.AddWindow(1) | |||
| 200 | + | w.AddWindow(2) | |||
| 201 | + | w.Apply(ActionToggleFloating) | |||
| 202 | + | i := w.floatIndex(2) | |||
| 203 | + | w.Floats[i].W, w.Floats[i].H = 200, 100 | |||
| 204 | + | r := w.floatRect(w.Floats[i]) | |||
| 205 | + | w.Apply(ActionMoveColumnLeft) | |||
| 206 | + | w.Apply(ActionMoveWindowDown) | |||
| 207 | + | if got := w.floatRect(w.Floats[i]); got.X != r.X-freeNudge || got.Y != r.Y+freeNudge { | |||
| 208 | + | t.Fatal(got, r) | |||
| 209 | + | } | |||
| 210 | + | for range 50 { | |||
| 211 | + | w.Apply(ActionMoveColumnLeft) | |||
| 212 | + | } | |||
| 213 | + | if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X { | |||
| 214 | + | t.Fatal(got) | |||
| 215 | + | } | |||
| 216 | + | w.Apply("set-column-width +10%") | |||
| 217 | + | w.Apply("set-window-height -10%") | |||
| 218 | + | if w.Floats[i].W != 200+w.Usable.W/10 || w.Floats[i].H != freeMinSize { | |||
| 219 | + | t.Fatal(w.Floats[i]) | |||
| 220 | + | } | |||
| 221 | + | // The columns did not change. | |||
| 222 | + | if len(w.Columns) != 1 || w.Columns[0].Width != (Width{}) { | |||
| 223 | + | t.Fatal(w.Columns) | |||
| 224 | + | } | |||
| 225 | + | } | |||
M internal/core/layout.go +34 -8
| @@ -2,6 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "math" | |
| 5 | 6 | "math/bits" | |
| 6 | 7 | "slices" | |
| 7 | 8 | "strconv" | |
| @@ -216,8 +217,14 @@ type Float struct { | |||
| 216 | 217 | W, H int | |
| 217 | 218 | // below places a covering native float behind columns and stash. | |
| 218 | 219 | below bool | |
| 219 | - | // back records the former column of a stashed window; nil for native floats. | |
| 220 | + | // back records the former column of a stashed window or a free float; | |
| 221 | + | // nil for native floats. | |
| 220 | 222 | back *origPlace | |
| 223 | + | // free marks a window the user floated (toggle-floating) or dragged: | |
| 224 | + | // core owns its size and its centre is at cx, cy, fractions of the | |
| 225 | + | // usable area, so it keeps its place on any output. | |
| 226 | + | free bool | |
| 227 | + | cx, cy float64 | |
| 221 | 228 | } | |
| 222 | 229 | ||
| 223 | 230 | func (w *Workspace) empty() bool { | |
| @@ -304,9 +311,10 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||
| 304 | 311 | w.floatFocus = true | |
| 305 | 312 | } | |
| 306 | 313 | ||
| 307 | - | // ResizeFloating records the size a floating window draws. | |
| 314 | + | // ResizeFloating records the size a floating window draws. Core sizes a | |
| 315 | + | // free float: its reports only fill a missing size. | |
| 308 | 316 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | |
| 309 | - | if i := w.floatIndex(id); i >= 0 { | |
| 317 | + | if i := w.floatIndex(id); i >= 0 && (!w.Floats[i].free || w.Floats[i].W <= 0) { | |
| 310 | 318 | w.Floats[i].W, w.Floats[i].H = width, height | |
| 311 | 319 | w.reconcileFloats() | |
| 312 | 320 | } | |
| @@ -1466,8 +1474,9 @@ func setVisibleNeighbors(tiles []Placement, gap int, output Rect) { | |||
| 1466 | 1474 | } | |
| 1467 | 1475 | } | |
| 1468 | 1476 | ||
| 1469 | - | // floatRect centres a native floating window in the usable area, its | |
| 1470 | - | // border around its client size, clamped to the area. | |
| 1477 | + | // floatRect places a floating window, its border around its client size, | |
| 1478 | + | // clamped to the usable area: a native one centred, a free one at its | |
| 1479 | + | // centre, kept inside the area. | |
| 1471 | 1480 | func (w *Workspace) floatRect(f Float) Rect { | |
| 1472 | 1481 | u := w.Usable | |
| 1473 | 1482 | fw, fh := f.W, f.H | |
| @@ -1475,12 +1484,24 @@ func (w *Workspace) floatRect(f Float) Rect { | |||
| 1475 | 1484 | fw, fh = u.W/2, u.H/2 | |
| 1476 | 1485 | } | |
| 1477 | 1486 | fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H) | |
| 1478 | - | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |
| 1487 | + | if !f.free { | |
| 1488 | + | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |
| 1489 | + | } | |
| 1490 | + | x := u.X + int(math.Round(f.cx*float64(u.W))) - fw/2 | |
| 1491 | + | y := u.Y + int(math.Round(f.cy*float64(u.H))) - fh/2 | |
| 1492 | + | x = min(max(x, u.X), u.X+u.W-fw) | |
| 1493 | + | y = min(max(y, u.Y), u.Y+u.H-fh) | |
| 1494 | + | return Rect{X: x, Y: y, W: fw, H: fh} | |
| 1479 | 1495 | } | |
| 1480 | 1496 | ||
| 1481 | 1497 | // imposedFloat reports whether core, rather than the client, owns the | |
| 1482 | - | // size of a floating window: a stashed one. | |
| 1483 | - | func (w *Workspace) imposedFloat(id WindowID) bool { return w.stashIndex(id) >= 0 } | |
| 1498 | + | // size of a floating window: a stashed one or a free float. | |
| 1499 | + | func (w *Workspace) imposedFloat(id WindowID) bool { | |
| 1500 | + | if i := w.floatIndex(id); i >= 0 { | |
| 1501 | + | return w.Floats[i].free | |
| 1502 | + | } | |
| 1503 | + | return w.stashIndex(id) >= 0 | |
| 1504 | + | } | |
| 1484 | 1505 | ||
| 1485 | 1506 | // ToggleWindowStash stashes the focused tile, or returns the focused | |
| 1486 | 1507 | // stashed window to its column. A native float becomes a new column. | |
| @@ -1492,6 +1513,11 @@ func (w *Workspace) ToggleWindowStash() { | |||
| 1492 | 1513 | return | |
| 1493 | 1514 | } | |
| 1494 | 1515 | switch { | |
| 1516 | + | case w.floatIndex(id) >= 0 && w.Floats[w.floatIndex(id)].free: | |
| 1517 | + | // A free float keeps its place and size in the stash. | |
| 1518 | + | f := w.Floats[w.floatIndex(id)] | |
| 1519 | + | w.RemoveWindow(id) | |
| 1520 | + | w.addStash(f) | |
| 1495 | 1521 | case w.floatIndex(id) >= 0: | |
| 1496 | 1522 | w.RemoveWindow(id) | |
| 1497 | 1523 | w.restore(id, nil) | |
| @@ -2,6 +2,7 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "math" | |||
| 5 | "math/bits" | 6 | "math/bits" | ||
| 6 | "slices" | 7 | "slices" | ||
| 7 | "strconv" | 8 | "strconv" | ||
| @@ -216,8 +217,14 @@ type Float struct { | |||||
| 216 | W, H int | 217 | W, H int | ||
| 217 | // below places a covering native float behind columns and stash. | 218 | // below places a covering native float behind columns and stash. | ||
| 218 | below bool | 219 | below bool | ||
| 219 | - | // back records the former column of a stashed window; nil for native floats. | 220 | + | // back records the former column of a stashed window or a free float; |
| 221 | + | // nil for native floats. | |||
| 220 | back *origPlace | 222 | back *origPlace | ||
| 223 | + | // free marks a window the user floated (toggle-floating) or dragged: | |||
| 224 | + | // core owns its size and its centre is at cx, cy, fractions of the | |||
| 225 | + | // usable area, so it keeps its place on any output. | |||
| 226 | + | free bool | |||
| 227 | + | cx, cy float64 | |||
| 221 | } | 228 | } | ||
| 222 | 229 | ||||
| 223 | func (w *Workspace) empty() bool { | 230 | func (w *Workspace) empty() bool { | ||
| @@ -304,9 +311,10 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||||
| 304 | w.floatFocus = true | 311 | w.floatFocus = true | ||
| 305 | } | 312 | } | ||
| 306 | 313 | ||||
| 307 | - | // ResizeFloating records the size a floating window draws. | 314 | + | // ResizeFloating records the size a floating window draws. Core sizes a |
| 315 | + | // free float: its reports only fill a missing size. | |||
| 308 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | 316 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | ||
| 309 | - | if i := w.floatIndex(id); i >= 0 { | 317 | + | if i := w.floatIndex(id); i >= 0 && (!w.Floats[i].free || w.Floats[i].W <= 0) { |
| 310 | w.Floats[i].W, w.Floats[i].H = width, height | 318 | w.Floats[i].W, w.Floats[i].H = width, height | ||
| 311 | w.reconcileFloats() | 319 | w.reconcileFloats() | ||
| 312 | } | 320 | } | ||
| @@ -1466,8 +1474,9 @@ func setVisibleNeighbors(tiles []Placement, gap int, output Rect) { | |||||
| 1466 | } | 1474 | } | ||
| 1467 | } | 1475 | } | ||
| 1468 | 1476 | ||||
| 1469 | - | // floatRect centres a native floating window in the usable area, its | 1477 | + | // floatRect places a floating window, its border around its client size, |
| 1470 | - | // border around its client size, clamped to the area. | 1478 | + | // clamped to the usable area: a native one centred, a free one at its |
| 1479 | + | // centre, kept inside the area. | |||
| 1471 | func (w *Workspace) floatRect(f Float) Rect { | 1480 | func (w *Workspace) floatRect(f Float) Rect { | ||
| 1472 | u := w.Usable | 1481 | u := w.Usable | ||
| 1473 | fw, fh := f.W, f.H | 1482 | fw, fh := f.W, f.H | ||
| @@ -1475,12 +1484,24 @@ func (w *Workspace) floatRect(f Float) Rect { | |||||
| 1475 | fw, fh = u.W/2, u.H/2 | 1484 | fw, fh = u.W/2, u.H/2 | ||
| 1476 | } | 1485 | } | ||
| 1477 | fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H) | 1486 | fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H) | ||
| 1478 | - | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | 1487 | + | if !f.free { |
| 1488 | + | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |||
| 1489 | + | } | |||
| 1490 | + | x := u.X + int(math.Round(f.cx*float64(u.W))) - fw/2 | |||
| 1491 | + | y := u.Y + int(math.Round(f.cy*float64(u.H))) - fh/2 | |||
| 1492 | + | x = min(max(x, u.X), u.X+u.W-fw) | |||
| 1493 | + | y = min(max(y, u.Y), u.Y+u.H-fh) | |||
| 1494 | + | return Rect{X: x, Y: y, W: fw, H: fh} | |||
| 1479 | } | 1495 | } | ||
| 1480 | 1496 | ||||
| 1481 | // imposedFloat reports whether core, rather than the client, owns the | 1497 | // imposedFloat reports whether core, rather than the client, owns the | ||
| 1482 | - | // size of a floating window: a stashed one. | 1498 | + | // size of a floating window: a stashed one or a free float. |
| 1483 | - | func (w *Workspace) imposedFloat(id WindowID) bool { return w.stashIndex(id) >= 0 } | 1499 | + | func (w *Workspace) imposedFloat(id WindowID) bool { |
| 1500 | + | if i := w.floatIndex(id); i >= 0 { | |||
| 1501 | + | return w.Floats[i].free | |||
| 1502 | + | } | |||
| 1503 | + | return w.stashIndex(id) >= 0 | |||
| 1504 | + | } | |||
| 1484 | 1505 | ||||
| 1485 | // ToggleWindowStash stashes the focused tile, or returns the focused | 1506 | // ToggleWindowStash stashes the focused tile, or returns the focused | ||
| 1486 | // stashed window to its column. A native float becomes a new column. | 1507 | // stashed window to its column. A native float becomes a new column. | ||
| @@ -1492,6 +1513,11 @@ func (w *Workspace) ToggleWindowStash() { | |||||
| 1492 | return | 1513 | return | ||
| 1493 | } | 1514 | } | ||
| 1494 | switch { | 1515 | switch { | ||
| 1516 | + | case w.floatIndex(id) >= 0 && w.Floats[w.floatIndex(id)].free: | |||
| 1517 | + | // A free float keeps its place and size in the stash. | |||
| 1518 | + | f := w.Floats[w.floatIndex(id)] | |||
| 1519 | + | w.RemoveWindow(id) | |||
| 1520 | + | w.addStash(f) | |||
| 1495 | case w.floatIndex(id) >= 0: | 1521 | case w.floatIndex(id) >= 0: | ||
| 1496 | w.RemoveWindow(id) | 1522 | w.RemoveWindow(id) | ||
| 1497 | w.restore(id, nil) | 1523 | w.restore(id, nil) | ||
M internal/core/monitor.go +7 -0
| @@ -345,6 +345,10 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||
| 345 | 345 | } | |
| 346 | 346 | if f := cur.floatIndex(id); f >= 0 { | |
| 347 | 347 | fl := cur.Floats[f] | |
| 348 | + | if fl.free && fl.back != nil { | |
| 349 | + | // Its column place is on this workspace. | |
| 350 | + | fl = fl.rehome() | |
| 351 | + | } | |
| 348 | 352 | cur.RemoveWindow(id) | |
| 349 | 353 | to.AddFloating(id, fl.W, fl.H) | |
| 350 | 354 | to.Floats[len(to.Floats)-1] = fl | |
| @@ -538,6 +542,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||
| 538 | 542 | } | |
| 539 | 543 | } | |
| 540 | 544 | for _, f := range w.Floats { | |
| 545 | + | if f.free && f.back != nil { | |
| 546 | + | f = f.rehome() | |
| 547 | + | } | |
| 541 | 548 | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | |
| 542 | 549 | m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f | |
| 543 | 550 | } | |
| @@ -345,6 +345,10 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||||
| 345 | } | 345 | } | ||
| 346 | if f := cur.floatIndex(id); f >= 0 { | 346 | if f := cur.floatIndex(id); f >= 0 { | ||
| 347 | fl := cur.Floats[f] | 347 | fl := cur.Floats[f] | ||
| 348 | + | if fl.free && fl.back != nil { | |||
| 349 | + | // Its column place is on this workspace. | |||
| 350 | + | fl = fl.rehome() | |||
| 351 | + | } | |||
| 348 | cur.RemoveWindow(id) | 352 | cur.RemoveWindow(id) | ||
| 349 | to.AddFloating(id, fl.W, fl.H) | 353 | to.AddFloating(id, fl.W, fl.H) | ||
| 350 | to.Floats[len(to.Floats)-1] = fl | 354 | to.Floats[len(to.Floats)-1] = fl | ||
| @@ -538,6 +542,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||||
| 538 | } | 542 | } | ||
| 539 | } | 543 | } | ||
| 540 | for _, f := range w.Floats { | 544 | for _, f := range w.Floats { | ||
| 545 | + | if f.free && f.back != nil { | |||
| 546 | + | f = f.rehome() | |||
| 547 | + | } | |||
| 541 | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | 548 | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | ||
| 542 | m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f | 549 | m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f | ||
| 543 | } | 550 | } | ||
M internal/core/stash.go +15 -3
| @@ -42,6 +42,13 @@ func (w *Workspace) onFloat() bool { return w.floatFocus || w.stashFocused() } | |||
| 42 | 42 | // stashWindow moves the focused tile id to the end of the stash, | |
| 43 | 43 | // remembering its place in its column to return there. | |
| 44 | 44 | func (w *Workspace) stashWindow(id WindowID) { | |
| 45 | + | back := w.tilePlace(id) | |
| 46 | + | w.RemoveWindow(id) | |
| 47 | + | w.addStash(Float{ID: id, back: back}) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // tilePlace is where the focused tile id is in its column, for restore. | |
| 51 | + | func (w *Workspace) tilePlace(id WindowID) *origPlace { | |
| 45 | 52 | c := w.Columns[w.Focus] | |
| 46 | 53 | lone := len(c.Windows) == 1 | |
| 47 | 54 | back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone} | |
| @@ -50,8 +57,7 @@ func (w *Workspace) stashWindow(id WindowID) { | |||
| 50 | 57 | back.stacked = append(back.stacked, v) | |
| 51 | 58 | } | |
| 52 | 59 | } | |
| 53 | - | w.RemoveWindow(id) | |
| 54 | - | w.addStash(Float{ID: id, back: back}) | |
| 60 | + | return back | |
| 55 | 61 | } | |
| 56 | 62 | ||
| 57 | 63 | // addStash appends a window to the stash, shows the stash and selects it. | |
| @@ -72,10 +78,16 @@ func (w *Workspace) showStash() { | |||
| 72 | 78 | w.hiddenFullscreen = 0 | |
| 73 | 79 | } | |
| 74 | 80 | ||
| 75 | - | // unstash returns stash entry i to its former column, focused. | |
| 81 | + | // unstash returns stash entry i to its former column, focused; a free | |
| 82 | + | // float floats again where it was. | |
| 76 | 83 | func (w *Workspace) unstash(i int) { | |
| 77 | 84 | f := w.Stash[i] | |
| 78 | 85 | w.RemoveWindow(f.ID) | |
| 86 | + | if f.free { | |
| 87 | + | w.Floats = append(w.Floats, f) | |
| 88 | + | w.floatFocus, w.stashFocus = true, false | |
| 89 | + | return | |
| 90 | + | } | |
| 79 | 91 | w.restore(f.ID, f.back) | |
| 80 | 92 | } | |
| 81 | 93 | ||
| @@ -42,6 +42,13 @@ func (w *Workspace) onFloat() bool { return w.floatFocus || w.stashFocused() } | |||||
| 42 | // stashWindow moves the focused tile id to the end of the stash, | 42 | // stashWindow moves the focused tile id to the end of the stash, | ||
| 43 | // remembering its place in its column to return there. | 43 | // remembering its place in its column to return there. | ||
| 44 | func (w *Workspace) stashWindow(id WindowID) { | 44 | func (w *Workspace) stashWindow(id WindowID) { | ||
| 45 | + | back := w.tilePlace(id) | |||
| 46 | + | w.RemoveWindow(id) | |||
| 47 | + | w.addStash(Float{ID: id, back: back}) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // tilePlace is where the focused tile id is in its column, for restore. | |||
| 51 | + | func (w *Workspace) tilePlace(id WindowID) *origPlace { | |||
| 45 | c := w.Columns[w.Focus] | 52 | c := w.Columns[w.Focus] | ||
| 46 | lone := len(c.Windows) == 1 | 53 | lone := len(c.Windows) == 1 | ||
| 47 | back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone} | 54 | back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone} | ||
| @@ -50,8 +57,7 @@ func (w *Workspace) stashWindow(id WindowID) { | |||||
| 50 | back.stacked = append(back.stacked, v) | 57 | back.stacked = append(back.stacked, v) | ||
| 51 | } | 58 | } | ||
| 52 | } | 59 | } | ||
| 53 | - | w.RemoveWindow(id) | 60 | + | return back |
| 54 | - | w.addStash(Float{ID: id, back: back}) | |||
| 55 | } | 61 | } | ||
| 56 | 62 | ||||
| 57 | // addStash appends a window to the stash, shows the stash and selects it. | 63 | // addStash appends a window to the stash, shows the stash and selects it. | ||
| @@ -72,10 +78,16 @@ func (w *Workspace) showStash() { | |||||
| 72 | w.hiddenFullscreen = 0 | 78 | w.hiddenFullscreen = 0 | ||
| 73 | } | 79 | } | ||
| 74 | 80 | ||||
| 75 | - | // unstash returns stash entry i to its former column, focused. | 81 | + | // unstash returns stash entry i to its former column, focused; a free |
| 82 | + | // float floats again where it was. | |||
| 76 | func (w *Workspace) unstash(i int) { | 83 | func (w *Workspace) unstash(i int) { | ||
| 77 | f := w.Stash[i] | 84 | f := w.Stash[i] | ||
| 78 | w.RemoveWindow(f.ID) | 85 | w.RemoveWindow(f.ID) | ||
| 86 | + | if f.free { | |||
| 87 | + | w.Floats = append(w.Floats, f) | |||
| 88 | + | w.floatFocus, w.stashFocus = true, false | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 79 | w.restore(f.ID, f.back) | 91 | w.restore(f.ID, f.back) | ||
| 80 | } | 92 | } | ||
| 81 | 93 | ||||