detached 6a1f994

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 UnverifiedB5690EEEBB952194 The 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
119119 | `close-window` | Close the focused window |
120120 | `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 |
121121 | `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 |
122123 | `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 |
123124 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
124125 | `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
148149
149150 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.
150151
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+
151158 ### Overview
152159
153160 `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+
199206 | `cmd+shift+f` | `toggle-fullscreen` |
200207 | `cmd+s` | `toggle-stash-visible` |
201208 | `cmd+shift+s` | `toggle-window-stash` |
209+| `cmd+shift+space` | `toggle-floating` |
202210 | `cmd+o` | `toggle-overview` |
203211 | `cmd+q` | `close-window` |
204212 | `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 ### Overview158 ### 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 {
166166 // A bind acts on the window on screen: under a covering fullscreen
167167 // window, that is the one Focused reports.
168168 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+ }
169173 if name, ok := NamedArg(a); ok && c.bringNamed(name) {
170174 return Effect{}
171175 }
@@ -381,6 +385,9 @@ func (w *Workspace) Apply(a Action) Effect {
381385 if argv, ok := SpawnArgv(a); ok {
382386 return Effect{Spawn: true, Argv: argv}
383387 }
388+ if w.freeAction(a) {
389+ return Effect{}
390+ }
384391 if axis, pct, ok := ResizeArg(a); ok {
385392 if axis == ResizeWidth {
386393 w.ResizeColumn(pct)
@@ -418,6 +425,8 @@ func (w *Workspace) Apply(a Action) Effect {
418425 w.ToggleWindowStash()
419426 case ActionToggleStashVisible:
420427 w.ToggleStashVisible()
428+ case ActionToggleFloating:
429+ w.ToggleFloating()
421430 case ActionCloseWindow:
422431 id, _ := w.Focused()
423432 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 fullscreen166 // 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
22
33 import (
44 "fmt"
5+ "math"
56 "math/bits"
67 "slices"
78 "strconv"
@@ -216,8 +217,14 @@ type Float struct {
216217 W, H int
217218 // below places a covering native float behind columns and stash.
218219 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.
220222 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
221228 }
222229
223230 func (w *Workspace) empty() bool {
@@ -304,9 +311,10 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
304311 w.floatFocus = true
305312 }
306313
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.
308316 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) {
310318 w.Floats[i].W, w.Floats[i].H = width, height
311319 w.reconcileFloats()
312320 }
@@ -1466,8 +1474,9 @@ func setVisibleNeighbors(tiles []Placement, gap int, output Rect) {
14661474 }
14671475 }
14681476
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.
14711480 func (w *Workspace) floatRect(f Float) Rect {
14721481 u := w.Usable
14731482 fw, fh := f.W, f.H
@@ -1475,12 +1484,24 @@ func (w *Workspace) floatRect(f Float) Rect {
14751484 fw, fh = u.W/2, u.H/2
14761485 }
14771486 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}
14791495 }
14801496
14811497 // 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+}
14841505
14851506 // ToggleWindowStash stashes the focused tile, or returns the focused
14861507 // stashed window to its column. A native float becomes a new column.
@@ -1492,6 +1513,11 @@ func (w *Workspace) ToggleWindowStash() {
14921513 return
14931514 }
14941515 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)
14951521 case w.floatIndex(id) >= 0:
14961522 w.RemoveWindow(id)
14971523 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 int217 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 bool219 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 *origPlace222 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 = true311 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, height318 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, its1477+// 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.Usable1481 u := w.Usable
1473 fw, fh := f.W, f.H1482 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/21484 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 the1497 // 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 focused1506 // 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 return1513 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) {
345345 }
346346 if f := cur.floatIndex(id); f >= 0 {
347347 fl := cur.Floats[f]
348+ if fl.free && fl.back != nil {
349+ // Its column place is on this workspace.
350+ fl = fl.rehome()
351+ }
348352 cur.RemoveWindow(id)
349353 to.AddFloating(id, fl.W, fl.H)
350354 to.Floats[len(to.Floats)-1] = fl
@@ -538,6 +542,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
538542 }
539543 }
540544 for _, f := range w.Floats {
545+ if f.free && f.back != nil {
546+ f = f.rehome()
547+ }
541548 m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
542549 m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f
543550 }
@@ -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] = fl354 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] = f549 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() }
4242 // stashWindow moves the focused tile id to the end of the stash,
4343 // remembering its place in its column to return there.
4444 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 {
4552 c := w.Columns[w.Focus]
4653 lone := len(c.Windows) == 1
4754 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) {
5057 back.stacked = append(back.stacked, v)
5158 }
5259 }
53- w.RemoveWindow(id)
54- w.addStash(Float{ID: id, back: back})
60+ return back
5561 }
5662
5763 // addStash appends a window to the stash, shows the stash and selects it.
@@ -72,10 +78,16 @@ func (w *Workspace) showStash() {
7278 w.hiddenFullscreen = 0
7379 }
7480
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.
7683 func (w *Workspace) unstash(i int) {
7784 f := w.Stash[i]
7885 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+ }
7991 w.restore(f.ID, f.back)
8092 }
8193
@@ -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) == 153 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 = 078 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close