detached d53d2a4

feat(overview): introduce two-dimensional cascade previews

commit
d53d2a464bf2b38a73cc903705364fc146b04461
author
brice · · 2026-10-06 09:45 UTC
parent
c6b6c21
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +178 -26
M docs/config.md +2 -2
@@ -196,7 +196,7 @@ Left/right focus follows column order across band boundaries. Up/down visits sta
196196
197197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour.
198198
199-The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
199+The overview presents bands vertically in one workspace card, with workspace neighbors arranged horizontally. Long previews center on the selected band. The stash keeps its smaller, independently scaled pile on the left. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
200200
201201 ### Actions
202202
@@ -257,7 +257,7 @@ The drop can be on another monitor; focus follows the window. There, the middle
257257
258258 ### Overview
259259
260-`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. 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 front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
260+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. 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 overview; workspace neighbors are vertical in scroll/fixed and horizontal when the current workspace uses Cascade. 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 follow its workspace-navigation axis; stash cards retain their own scale. 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 front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
261261
262262 | Key | Action |
263263 | --- | --- |
@@ -196,7 +196,7 @@ Left/right focus follows column order across band boundaries. Up/down visits sta
196 196
197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour.197 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-up/down` commands still mean previous/next workspace, even when the transition is horizontal. Four-finger and stash gestures keep their existing behaviour.
198 198
199-The overview presents bands side by side in one workspace card. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.199+The overview presents bands vertically in one workspace card, with workspace neighbors arranged horizontally. Long previews center on the selected band. The stash keeps its smaller, independently scaled pile on the left. Selecting a window reveals its band. Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
200 200
201 ### Actions201 ### Actions
202 202
@@ -257,7 +257,7 @@ The drop can be on another monitor; focus follows the window. There, the middle
257 257
258 ### Overview258 ### Overview
259 259
260-`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. 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 front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.260+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. 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 overview; workspace neighbors are vertical in scroll/fixed and horizontal when the current workspace uses Cascade. 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 follow its workspace-navigation axis; stash cards retain their own scale. 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 front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
261 261
262 | Key | Action |262 | Key | Action |
263 | --- | --- |263 | --- | --- |
M internal/core/core.go +6 -0
@@ -287,6 +287,12 @@ func (c *Core) apply(cfg ports.Config) error {
287287 if err != nil {
288288 return err
289289 }
290+ // A successful configuration update starts a fresh overview gesture.
291+ // Selection and opening snapshots remain identity-based across layouts.
292+ for _, s := range c.screens {
293+ s.mon.ov.scrollX, s.mon.ov.scrollY = 0, 0
294+ s.mon.ov.scrolled, s.mon.ov.sideways = false, false
295+ }
290296 c.cfg = cfg
291297 c.binds = binds
292298 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
@@ -287,6 +287,12 @@ func (c *Core) apply(cfg ports.Config) error {
287 if err != nil {287 if err != nil {
288 return err288 return err
289 }289 }
290+ // A successful configuration update starts a fresh overview gesture.
291+ // Selection and opening snapshots remain identity-based across layouts.
292+ for _, s := range c.screens {
293+ s.mon.ov.scrollX, s.mon.ov.scrollY = 0, 0
294+ s.mon.ov.scrolled, s.mon.ov.sideways = false, false
295+ }
290 c.cfg = cfg296 c.cfg = cfg
291 c.binds = binds297 c.binds = binds
292 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]298 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
M internal/core/overview.go +90 -21
@@ -353,6 +353,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
353353 // a named group uses its invocation anchor as the toggle return target, not
354354 // rows merely browsed in the overview.
355355 func (m *Monitor) showOverview(w *Workspace) {
356+ if w.policy().workspace != m.Current().policy().workspace {
357+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false
358+ }
356359 if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {
357360 m.back = m.Current()
358361 if indexOf(m.Workspaces, w.overviewAfter) >= 0 {
@@ -490,13 +493,13 @@ func (m *Monitor) OverviewPick(id WindowID) {
490493 // overviewRows centers the selected row with its neighbors above and below.
491494 // The numbered/named boundary gets extra spacing and an inactive horizontal
492495 // rule. Row widths, stash piles and preview scales remain unchanged.
493-func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {
496+func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
494497 cur := m.Current()
495498 u := cur.overviewArea()
496499 gap := max(1, u.H*3/100)
497500 hc := cur.rowHeight()
498501 y := u.Y + (u.H-hc)/2
499- rows := map[*Workspace]int{cur: y}
502+ rows := map[*Workspace]Rect{cur: {Y: y}}
500503 var dividers []ports.Separator
501504 list := m.overviewWorkspaces()
502505 i := indexOf(list, cur)
@@ -512,14 +515,29 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {
512515 }
513516 top := y + hc
514517 if next < i {
515- rows[w] = y - spacing - w.rowHeight()
518+ rows[w] = Rect{Y: y - spacing - w.rowHeight()}
516519 top = y - spacing
517520 } else {
518- rows[w] = y + hc + spacing
521+ rows[w] = Rect{Y: y + hc + spacing}
522+ }
523+ if cur.policy().workspace == horizontalAxis {
524+ dx := u.W + spacing
525+ if next < i {
526+ dx = -dx
527+ }
528+ rows[w] = Rect{X: dx, Y: u.Y + (u.H-w.rowHeight())/2}
519529 }
520530 if boundary {
521531 margin := u.W * 4 / 100
522- dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}})
532+ r := Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}
533+ if cur.policy().workspace == horizontalAxis {
534+ x := u.X + u.W + spacing/2
535+ if next < i {
536+ x = u.X - spacing/2
537+ }
538+ r = Rect{X: x, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}
539+ }
540+ dividers = append(dividers, ports.Separator{Rect: r})
523541 }
524542 }
525543 return rows, dividers
@@ -535,8 +553,14 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
535553 ry, shown := rows[w]
536554 if shown {
537555 at := m.cardAt(w)
538- result = append(result, m.stackRow(w, ry, w != cur, at < 0)...)
539- result = append(result, w.pile(ry, w != cur, at)...)
556+ start := len(result)
557+ result = append(result, m.stackRow(w, ry.Y, w != cur, at < 0)...)
558+ result = append(result, w.pile(ry.Y, w != cur, at)...)
559+ for i := start; i < len(result); i++ {
560+ if !result[i].Hidden {
561+ result[i].Rect.X += ry.X
562+ }
563+ }
540564 }
541565 for _, f := range w.Floats {
542566 if _, drawn := w.itemOf(f.ID); !shown || !drawn {
@@ -656,6 +680,12 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
656680 cw := int(math.Round(float64(r.W) * overviewCardZoom))
657681 ch := int(math.Round(float64(r.H) * overviewCardZoom))
658682 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
683+ if w.policy().wraps {
684+ rowH = w.rowHeight() - 4*w.peekStep()
685+ if len(w.stack()) <= 1 {
686+ rowH = w.rowHeight()
687+ }
688+ }
659689 // Stack rows reserve two peek steps above the front card. Align the
660690 // stash with that front band, not with the top of its envelope.
661691 x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2
@@ -673,13 +703,18 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
673703 // width, within the overview bounds.
674704 func (w *Workspace) overviewZoom() float64 {
675705 // Only the span is read: the tiles go in the zoom scratch, dead on return.
676- var span int
677- w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
706+ var preview overviewPreview
707+ w.zoomBuf, preview = w.previewTilesInto(w.zoomBuf[:0])
708+ span := preview.bounds.W
678709 if span <= 0 {
679710 return overviewMaxZoom
680711 }
681712 room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
682- z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
713+ fit := room / float64(span)
714+ if w.policy().wraps && preview.bounds.H > 0 {
715+ fit = min(fit, float64(w.overviewArea().H)*0.8/float64(preview.bounds.H))
716+ }
717+ z := min(max(fit, overviewMinZoom), overviewMaxZoom)
683718 if len(w.stack()) > 1 {
684719 // Reserve two clear peeks on either side of the front row.
685720 z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))
@@ -704,7 +739,15 @@ func (w *Workspace) frontRowY(y int) int {
704739 // rowHeight includes the largest possible stack envelope (two peeks above
705740 // and two below). Neighbor rows use the same measure as the current row.
706741 func (w *Workspace) rowHeight() int {
707- h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
742+ height := w.Usable.H
743+ if w.policy().wraps {
744+ n, _ := w.cascadeBands()
745+ height *= n
746+ }
747+ h := int(math.Round(float64(height) * w.overviewZoom()))
748+ if w.policy().wraps {
749+ h = min(h, w.overviewArea().H*8/10)
750+ }
708751 if len(w.stack()) > 1 {
709752 h += 4 * w.peekStep()
710753 }
@@ -714,28 +757,33 @@ func (w *Workspace) rowHeight() int {
714757 // previewTiles lays tiles out unscrolled, relative to the usable area's
715758 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
716759 // an unscrolled row. span is the row's width; sel is the focused column.
717-func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
760+// overviewPreview describes unscaled content independently of its presentation.
761+type overviewPreview struct {
762+ bounds Rect
763+ selected Rect
764+}
765+
766+func (w *Workspace) previewTiles() ([]Placement, overviewPreview) {
718767 return w.previewTilesInto(make([]Placement, 0, w.columnWindows()))
719768 }
720769
721770 // previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its
722771 // storage: the result then aliases it). The column rects use w.colBuf, dead
723772 // when it returns.
724-func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) {
725- var span int
726- var sel Rect
773+func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, overviewPreview) {
774+ span, sel := 0, Rect{}
727775 if w.pinned() {
728776 c := w.cover()
729777 // The row shows the columns a pinned fullscreen window hides,
730778 // laid out as without it; its own tile stays marked fullscreen.
731779 v := *w
732780 v.fullscreen = 0
733- tiles, span, sel = v.previewTilesInto(tiles)
781+ tiles, preview := v.previewTilesInto(tiles)
734782 w.colBuf, w.rowBuf = v.colBuf, v.rowBuf
735783 for i := range tiles {
736784 tiles[i].Fullscreen = tiles[i].ID == c
737785 }
738- return tiles, span, sel
786+ return tiles, preview
739787 }
740788 g := w.gap()
741789 h := max(w.Usable.H-2*g, 0)
@@ -750,7 +798,8 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
750798 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
751799 if w.policy().wraps {
752800 width := w.cellWidth()
753- r.X = w.band(i)*w.Usable.W + w.cellX(i)
801+ r.X = w.cellX(i)
802+ r.Y = g + w.band(i)*w.Usable.H
754803 r.W = width
755804 } else if w.Overflow == OverflowFixed {
756805 r = rects[i]
@@ -779,7 +828,12 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
779828 sel = r
780829 }
781830 }
782- return tiles, span, sel
831+ bounds := Rect{W: span, H: w.Usable.H}
832+ if w.policy().wraps {
833+ n, _ := w.cascadeBands()
834+ bounds.W, bounds.H = w.Usable.W, n*w.Usable.H
835+ }
836+ return tiles, overviewPreview{bounds: bounds, selected: sel}
783837 }
784838
785839 // previewRow scales the tiles of w into a row whose top is at y, centred
@@ -788,11 +842,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
788842 // workspaces) have no focus; neither has a row whose pile has it (lit
789843 // false).
790844 func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {
791- tiles, span, sel := w.previewTiles()
792- return w.previewRowTiles(y, dim, lit, tiles, span, sel)
845+ tiles, preview := w.previewTiles()
846+ return w.previewGeometry(y, dim, lit, tiles, preview)
793847 }
794848
795849 func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {
850+ return w.previewGeometry(y, dim, lit, tiles, overviewPreview{bounds: Rect{W: span, H: w.Usable.H}, selected: sel})
851+}
852+
853+func (w *Workspace) previewGeometry(y int, dim, lit bool, tiles []Placement, preview overviewPreview) []Placement {
854+ span, sel := preview.bounds.W, preview.selected
796855 u := w.overviewArea()
797856 z := w.overviewZoom()
798857 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
@@ -806,6 +865,11 @@ func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, spa
806865 x = u.X + u.W/2 - scale(sel.X+sel.W/2)
807866 x = min(max(x, u.X+u.W-width), u.X)
808867 }
868+ if w.policy().wraps && preview.bounds.H > w.Usable.H {
869+ room := min(scale(preview.bounds.H), u.H*8/10)
870+ offset := room/2 - scale(sel.Y+sel.H/2)
871+ y += min(0, max(room-scale(preview.bounds.H), offset))
872+ }
809873 for i := range tiles {
810874 if tiles[i].Hidden {
811875 continue
@@ -954,6 +1018,11 @@ const (
9541018 // (touchpad.* or mouse.*) flips both axes as libinput reports. The end of
9551019 // a scroll resets it (scrollStop).
9561020 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1021+ // The accumulator uses logical content/workspace axes, independent of
1022+ // their physical orientation. Stash selection retains its own axes.
1023+ if m.Current().policy().wraps && m.cardAt(m.Current()) < 0 {
1024+ a.Vertical, a.Horizontal = a.Horizontal, a.Vertical
1025+ }
9571026 if a.Vertical.Stop || a.Horizontal.Stop {
9581027 return false
9591028 }
@@ -353,6 +353,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
353 // a named group uses its invocation anchor as the toggle return target, not353 // a named group uses its invocation anchor as the toggle return target, not
354 // rows merely browsed in the overview.354 // rows merely browsed in the overview.
355 func (m *Monitor) showOverview(w *Workspace) {355 func (m *Monitor) showOverview(w *Workspace) {
356+ if w.policy().workspace != m.Current().policy().workspace {
357+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false
358+ }
356 if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {359 if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {
357 m.back = m.Current()360 m.back = m.Current()
358 if indexOf(m.Workspaces, w.overviewAfter) >= 0 {361 if indexOf(m.Workspaces, w.overviewAfter) >= 0 {
@@ -490,13 +493,13 @@ func (m *Monitor) OverviewPick(id WindowID) {
490 // overviewRows centers the selected row with its neighbors above and below.493 // overviewRows centers the selected row with its neighbors above and below.
491 // The numbered/named boundary gets extra spacing and an inactive horizontal494 // The numbered/named boundary gets extra spacing and an inactive horizontal
492 // rule. Row widths, stash piles and preview scales remain unchanged.495 // rule. Row widths, stash piles and preview scales remain unchanged.
493-func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {496+func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
494 cur := m.Current()497 cur := m.Current()
495 u := cur.overviewArea()498 u := cur.overviewArea()
496 gap := max(1, u.H*3/100)499 gap := max(1, u.H*3/100)
497 hc := cur.rowHeight()500 hc := cur.rowHeight()
498 y := u.Y + (u.H-hc)/2501 y := u.Y + (u.H-hc)/2
499- rows := map[*Workspace]int{cur: y}502+ rows := map[*Workspace]Rect{cur: {Y: y}}
500 var dividers []ports.Separator503 var dividers []ports.Separator
501 list := m.overviewWorkspaces()504 list := m.overviewWorkspaces()
502 i := indexOf(list, cur)505 i := indexOf(list, cur)
@@ -512,14 +515,29 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {
512 }515 }
513 top := y + hc516 top := y + hc
514 if next < i {517 if next < i {
515- rows[w] = y - spacing - w.rowHeight()518+ rows[w] = Rect{Y: y - spacing - w.rowHeight()}
516 top = y - spacing519 top = y - spacing
517 } else {520 } else {
518- rows[w] = y + hc + spacing521+ rows[w] = Rect{Y: y + hc + spacing}
522+ }
523+ if cur.policy().workspace == horizontalAxis {
524+ dx := u.W + spacing
525+ if next < i {
526+ dx = -dx
527+ }
528+ rows[w] = Rect{X: dx, Y: u.Y + (u.H-w.rowHeight())/2}
519 }529 }
520 if boundary {530 if boundary {
521 margin := u.W * 4 / 100531 margin := u.W * 4 / 100
522- dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}})532+ r := Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}
533+ if cur.policy().workspace == horizontalAxis {
534+ x := u.X + u.W + spacing/2
535+ if next < i {
536+ x = u.X - spacing/2
537+ }
538+ r = Rect{X: x, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}
539+ }
540+ dividers = append(dividers, ports.Separator{Rect: r})
523 }541 }
524 }542 }
525 return rows, dividers543 return rows, dividers
@@ -535,8 +553,14 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
535 ry, shown := rows[w]553 ry, shown := rows[w]
536 if shown {554 if shown {
537 at := m.cardAt(w)555 at := m.cardAt(w)
538- result = append(result, m.stackRow(w, ry, w != cur, at < 0)...)556+ start := len(result)
539- result = append(result, w.pile(ry, w != cur, at)...)557+ result = append(result, m.stackRow(w, ry.Y, w != cur, at < 0)...)
558+ result = append(result, w.pile(ry.Y, w != cur, at)...)
559+ for i := start; i < len(result); i++ {
560+ if !result[i].Hidden {
561+ result[i].Rect.X += ry.X
562+ }
563+ }
540 }564 }
541 for _, f := range w.Floats {565 for _, f := range w.Floats {
542 if _, drawn := w.itemOf(f.ID); !shown || !drawn {566 if _, drawn := w.itemOf(f.ID); !shown || !drawn {
@@ -656,6 +680,12 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
656 cw := int(math.Round(float64(r.W) * overviewCardZoom))680 cw := int(math.Round(float64(r.W) * overviewCardZoom))
657 ch := int(math.Round(float64(r.H) * overviewCardZoom))681 ch := int(math.Round(float64(r.H) * overviewCardZoom))
658 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))682 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
683+ if w.policy().wraps {
684+ rowH = w.rowHeight() - 4*w.peekStep()
685+ if len(w.stack()) <= 1 {
686+ rowH = w.rowHeight()
687+ }
688+ }
659 // Stack rows reserve two peek steps above the front card. Align the689 // Stack rows reserve two peek steps above the front card. Align the
660 // stash with that front band, not with the top of its envelope.690 // stash with that front band, not with the top of its envelope.
661 x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2691 x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2
@@ -673,13 +703,18 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
673 // width, within the overview bounds.703 // width, within the overview bounds.
674 func (w *Workspace) overviewZoom() float64 {704 func (w *Workspace) overviewZoom() float64 {
675 // Only the span is read: the tiles go in the zoom scratch, dead on return.705 // Only the span is read: the tiles go in the zoom scratch, dead on return.
676- var span int706+ var preview overviewPreview
677- w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])707+ w.zoomBuf, preview = w.previewTilesInto(w.zoomBuf[:0])
708+ span := preview.bounds.W
678 if span <= 0 {709 if span <= 0 {
679 return overviewMaxZoom710 return overviewMaxZoom
680 }711 }
681 room := float64(w.overviewArea().W-w.pileWidth()) * 0.96712 room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
682- z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)713+ fit := room / float64(span)
714+ if w.policy().wraps && preview.bounds.H > 0 {
715+ fit = min(fit, float64(w.overviewArea().H)*0.8/float64(preview.bounds.H))
716+ }
717+ z := min(max(fit, overviewMinZoom), overviewMaxZoom)
683 if len(w.stack()) > 1 {718 if len(w.stack()) > 1 {
684 // Reserve two clear peeks on either side of the front row.719 // Reserve two clear peeks on either side of the front row.
685 z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))720 z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))
@@ -704,7 +739,15 @@ func (w *Workspace) frontRowY(y int) int {
704 // rowHeight includes the largest possible stack envelope (two peeks above739 // rowHeight includes the largest possible stack envelope (two peeks above
705 // and two below). Neighbor rows use the same measure as the current row.740 // and two below). Neighbor rows use the same measure as the current row.
706 func (w *Workspace) rowHeight() int {741 func (w *Workspace) rowHeight() int {
707- h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))742+ height := w.Usable.H
743+ if w.policy().wraps {
744+ n, _ := w.cascadeBands()
745+ height *= n
746+ }
747+ h := int(math.Round(float64(height) * w.overviewZoom()))
748+ if w.policy().wraps {
749+ h = min(h, w.overviewArea().H*8/10)
750+ }
708 if len(w.stack()) > 1 {751 if len(w.stack()) > 1 {
709 h += 4 * w.peekStep()752 h += 4 * w.peekStep()
710 }753 }
@@ -714,28 +757,33 @@ func (w *Workspace) rowHeight() int {
714 // previewTiles lays tiles out unscrolled, relative to the usable area's757 // previewTiles lays tiles out unscrolled, relative to the usable area's
715 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses758 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
716 // an unscrolled row. span is the row's width; sel is the focused column.759 // an unscrolled row. span is the row's width; sel is the focused column.
717-func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {760+// overviewPreview describes unscaled content independently of its presentation.
761+type overviewPreview struct {
762+ bounds Rect
763+ selected Rect
764+}
765+
766+func (w *Workspace) previewTiles() ([]Placement, overviewPreview) {
718 return w.previewTilesInto(make([]Placement, 0, w.columnWindows()))767 return w.previewTilesInto(make([]Placement, 0, w.columnWindows()))
719 }768 }
720 769
721 // previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its770 // previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its
722 // storage: the result then aliases it). The column rects use w.colBuf, dead771 // storage: the result then aliases it). The column rects use w.colBuf, dead
723 // when it returns.772 // when it returns.
724-func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) {773+func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, overviewPreview) {
725- var span int774+ span, sel := 0, Rect{}
726- var sel Rect
727 if w.pinned() {775 if w.pinned() {
728 c := w.cover()776 c := w.cover()
729 // The row shows the columns a pinned fullscreen window hides,777 // The row shows the columns a pinned fullscreen window hides,
730 // laid out as without it; its own tile stays marked fullscreen.778 // laid out as without it; its own tile stays marked fullscreen.
731 v := *w779 v := *w
732 v.fullscreen = 0780 v.fullscreen = 0
733- tiles, span, sel = v.previewTilesInto(tiles)781+ tiles, preview := v.previewTilesInto(tiles)
734 w.colBuf, w.rowBuf = v.colBuf, v.rowBuf782 w.colBuf, w.rowBuf = v.colBuf, v.rowBuf
735 for i := range tiles {783 for i := range tiles {
736 tiles[i].Fullscreen = tiles[i].ID == c784 tiles[i].Fullscreen = tiles[i].ID == c
737 }785 }
738- return tiles, span, sel786+ return tiles, preview
739 }787 }
740 g := w.gap()788 g := w.gap()
741 h := max(w.Usable.H-2*g, 0)789 h := max(w.Usable.H-2*g, 0)
@@ -750,7 +798,8 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
750 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}798 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
751 if w.policy().wraps {799 if w.policy().wraps {
752 width := w.cellWidth()800 width := w.cellWidth()
753- r.X = w.band(i)*w.Usable.W + w.cellX(i)801+ r.X = w.cellX(i)
802+ r.Y = g + w.band(i)*w.Usable.H
754 r.W = width803 r.W = width
755 } else if w.Overflow == OverflowFixed {804 } else if w.Overflow == OverflowFixed {
756 r = rects[i]805 r = rects[i]
@@ -779,7 +828,12 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
779 sel = r828 sel = r
780 }829 }
781 }830 }
782- return tiles, span, sel831+ bounds := Rect{W: span, H: w.Usable.H}
832+ if w.policy().wraps {
833+ n, _ := w.cascadeBands()
834+ bounds.W, bounds.H = w.Usable.W, n*w.Usable.H
835+ }
836+ return tiles, overviewPreview{bounds: bounds, selected: sel}
783 }837 }
784 838
785 // previewRow scales the tiles of w into a row whose top is at y, centred839 // previewRow scales the tiles of w into a row whose top is at y, centred
@@ -788,11 +842,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
788 // workspaces) have no focus; neither has a row whose pile has it (lit842 // workspaces) have no focus; neither has a row whose pile has it (lit
789 // false).843 // false).
790 func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {844 func (w *Workspace) previewRow(y int, dim, lit bool) []Placement {
791- tiles, span, sel := w.previewTiles()845+ tiles, preview := w.previewTiles()
792- return w.previewRowTiles(y, dim, lit, tiles, span, sel)846+ return w.previewGeometry(y, dim, lit, tiles, preview)
793 }847 }
794 848
795 func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {849 func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {
850+ return w.previewGeometry(y, dim, lit, tiles, overviewPreview{bounds: Rect{W: span, H: w.Usable.H}, selected: sel})
851+}
852+
853+func (w *Workspace) previewGeometry(y int, dim, lit bool, tiles []Placement, preview overviewPreview) []Placement {
854+ span, sel := preview.bounds.W, preview.selected
796 u := w.overviewArea()855 u := w.overviewArea()
797 z := w.overviewZoom()856 z := w.overviewZoom()
798 scale := func(v int) int { return int(math.Round(float64(v) * z)) }857 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
@@ -806,6 +865,11 @@ func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, spa
806 x = u.X + u.W/2 - scale(sel.X+sel.W/2)865 x = u.X + u.W/2 - scale(sel.X+sel.W/2)
807 x = min(max(x, u.X+u.W-width), u.X)866 x = min(max(x, u.X+u.W-width), u.X)
808 }867 }
868+ if w.policy().wraps && preview.bounds.H > w.Usable.H {
869+ room := min(scale(preview.bounds.H), u.H*8/10)
870+ offset := room/2 - scale(sel.Y+sel.H/2)
871+ y += min(0, max(room-scale(preview.bounds.H), offset))
872+ }
809 for i := range tiles {873 for i := range tiles {
810 if tiles[i].Hidden {874 if tiles[i].Hidden {
811 continue875 continue
@@ -954,6 +1018,11 @@ const (
954 // (touchpad.* or mouse.*) flips both axes as libinput reports. The end of1018 // (touchpad.* or mouse.*) flips both axes as libinput reports. The end of
955 // a scroll resets it (scrollStop).1019 // a scroll resets it (scrollStop).
956 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {1020 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1021+ // The accumulator uses logical content/workspace axes, independent of
1022+ // their physical orientation. Stash selection retains its own axes.
1023+ if m.Current().policy().wraps && m.cardAt(m.Current()) < 0 {
1024+ a.Vertical, a.Horizontal = a.Horizontal, a.Vertical
1025+ }
957 if a.Vertical.Stop || a.Horizontal.Stop {1026 if a.Vertical.Stop || a.Horizontal.Stop {
958 return false1027 return false
959 }1028 }
A internal/core/overview_cascade_test.go +77 -0
@@ -0,0 +1,77 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "testing"
6+)
7+
8+func TestOverviewCascadeBandGeometry(t *testing.T) {
9+ w := cascadeWorkspace(7)
10+ tiles, p := w.previewTiles()
11+ if p.bounds.W != w.Usable.W || p.bounds.H != 3*w.Usable.H {
12+ t.Fatalf("bounds=%v", p.bounds)
13+ }
14+ for i, tile := range tiles {
15+ if tile.Rect.X != w.cellX(i) || tile.Rect.Y != w.gap()+w.band(i)*w.Usable.H {
16+ t.Fatalf("tile %d=%v", i, tile.Rect)
17+ }
18+ }
19+}
20+
21+func TestOverviewCascadePreviewBufferAllocations(t *testing.T) {
22+ w := cascadeWorkspace(7)
23+ buf, _ := w.previewTiles()
24+ if n := testing.AllocsPerRun(100, func() { buf, _ = w.previewTilesInto(buf[:0]) }); n != 0 {
25+ t.Fatalf("allocations=%v", n)
26+ }
27+}
28+
29+func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
30+ m := overviewMonitor()
31+ m.SetOverflow(OverflowFixed)
32+ m.Current().Overflow = OverflowCascade
33+ from := m.Current()
34+ m.ToggleOverview()
35+ rows, _ := m.overviewRows()
36+ if rows[m.Workspaces[1]].X <= 0 {
37+ t.Fatal("cascade neighbor not horizontal")
38+ }
39+ m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
40+ if m.Current() == from {
41+ t.Fatal("horizontal gesture did not change workspace")
42+ }
43+ if m.Current().Overflow != OverflowFixed {
44+ t.Fatal("destination lost fixed policy")
45+ }
46+ m.CancelOverview()
47+ if m.Current() != from {
48+ t.Fatal("cancel lost source workspace")
49+ }
50+}
51+
52+func TestOverviewCascadeReloadRetainsSelection(t *testing.T) {
53+ m := overviewMonitor()
54+ m.ToggleOverview()
55+ w := m.Current()
56+ id, _ := w.Focused()
57+ m.SetOverflow(OverflowCascade)
58+ if got, _ := w.Focused(); !m.ov.open || got != id {
59+ t.Fatal("reload lost overview selection")
60+ }
61+ m.CancelOverview()
62+ if got, _ := w.Focused(); got != id {
63+ t.Fatal("cancel lost original focus")
64+ }
65+}
66+
67+func TestOverviewCascadeStashScale(t *testing.T) {
68+ w := cascadeWorkspace(7)
69+ w.Stash = []Float{{ID: 20}}
70+ cards := w.pile(0, false, 0)
71+ if len(cards) != 1 || cards[0].Preview != overviewCardZoom {
72+ t.Fatalf("cards=%v", cards)
73+ }
74+ if w.pileWidth() > w.overviewArea().W/3 {
75+ t.Fatal("stash exceeds width budget")
76+ }
77+}
@@ -0,0 +1,77 @@
1+package core
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "testing"
6+)
7+
8+func TestOverviewCascadeBandGeometry(t *testing.T) {
9+ w := cascadeWorkspace(7)
10+ tiles, p := w.previewTiles()
11+ if p.bounds.W != w.Usable.W || p.bounds.H != 3*w.Usable.H {
12+ t.Fatalf("bounds=%v", p.bounds)
13+ }
14+ for i, tile := range tiles {
15+ if tile.Rect.X != w.cellX(i) || tile.Rect.Y != w.gap()+w.band(i)*w.Usable.H {
16+ t.Fatalf("tile %d=%v", i, tile.Rect)
17+ }
18+ }
19+}
20+
21+func TestOverviewCascadePreviewBufferAllocations(t *testing.T) {
22+ w := cascadeWorkspace(7)
23+ buf, _ := w.previewTiles()
24+ if n := testing.AllocsPerRun(100, func() { buf, _ = w.previewTilesInto(buf[:0]) }); n != 0 {
25+ t.Fatalf("allocations=%v", n)
26+ }
27+}
28+
29+func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
30+ m := overviewMonitor()
31+ m.SetOverflow(OverflowFixed)
32+ m.Current().Overflow = OverflowCascade
33+ from := m.Current()
34+ m.ToggleOverview()
35+ rows, _ := m.overviewRows()
36+ if rows[m.Workspaces[1]].X <= 0 {
37+ t.Fatal("cascade neighbor not horizontal")
38+ }
39+ m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
40+ if m.Current() == from {
41+ t.Fatal("horizontal gesture did not change workspace")
42+ }
43+ if m.Current().Overflow != OverflowFixed {
44+ t.Fatal("destination lost fixed policy")
45+ }
46+ m.CancelOverview()
47+ if m.Current() != from {
48+ t.Fatal("cancel lost source workspace")
49+ }
50+}
51+
52+func TestOverviewCascadeReloadRetainsSelection(t *testing.T) {
53+ m := overviewMonitor()
54+ m.ToggleOverview()
55+ w := m.Current()
56+ id, _ := w.Focused()
57+ m.SetOverflow(OverflowCascade)
58+ if got, _ := w.Focused(); !m.ov.open || got != id {
59+ t.Fatal("reload lost overview selection")
60+ }
61+ m.CancelOverview()
62+ if got, _ := w.Focused(); got != id {
63+ t.Fatal("cancel lost original focus")
64+ }
65+}
66+
67+func TestOverviewCascadeStashScale(t *testing.T) {
68+ w := cascadeWorkspace(7)
69+ w.Stash = []Float{{ID: 20}}
70+ cards := w.pile(0, false, 0)
71+ if len(cards) != 1 || cards[0].Preview != overviewCardZoom {
72+ t.Fatalf("cards=%v", cards)
73+ }
74+ if w.pileWidth() > w.overviewArea().W/3 {
75+ t.Fatal("stash exceeds width budget")
76+ }
77+}
M internal/core/overview_test.go +2 -2
@@ -485,8 +485,8 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
485485 w.FocusID(6)
486486 screen := w.Layout()
487487 m.ToggleOverview()
488- if _, span, _ := w.previewTiles(); span != w.Usable.W {
489- t.Fatalf("span %d, usable %d", span, w.Usable.W)
488+ if _, preview := w.previewTiles(); preview.bounds.W != w.Usable.W {
489+ t.Fatalf("span %d, usable %d", preview.bounds.W, w.Usable.W)
490490 }
491491 ps := m.Layout()
492492 z := previewOf(t, ps, 6).Preview
@@ -485,8 +485,8 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
485 w.FocusID(6)485 w.FocusID(6)
486 screen := w.Layout()486 screen := w.Layout()
487 m.ToggleOverview()487 m.ToggleOverview()
488- if _, span, _ := w.previewTiles(); span != w.Usable.W {488+ if _, preview := w.previewTiles(); preview.bounds.W != w.Usable.W {
489- t.Fatalf("span %d, usable %d", span, w.Usable.W)489+ t.Fatalf("span %d, usable %d", preview.bounds.W, w.Usable.W)
490 }490 }
491 ps := m.Layout()491 ps := m.Layout()
492 z := previewOf(t, ps, 6).Preview492 z := previewOf(t, ps, 6).Preview
M internal/core/row_shares_test.go +1 -1
@@ -183,7 +183,7 @@ func TestOverviewPreviewUsesShares(t *testing.T) {
183183 w := workspace()
184184 w.SetOutput(1000, 800)
185185 w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}}
186- tiles, _, _ := w.previewTiles()
186+ tiles, _ := w.previewTiles()
187187 h := map[WindowID]int{}
188188 for _, p := range tiles {
189189 h[p.ID] = p.Rect.H
@@ -183,7 +183,7 @@ func TestOverviewPreviewUsesShares(t *testing.T) {
183 w := workspace()183 w := workspace()
184 w.SetOutput(1000, 800)184 w.SetOutput(1000, 800)
185 w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}}185 w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}}
186- tiles, _, _ := w.previewTiles()186+ tiles, _ := w.previewTiles()
187 h := map[WindowID]int{}187 h := map[WindowID]int{}
188 for _, p := range tiles {188 for _, p := range tiles {
189 h[p.ID] = p.Rect.H189 h[p.ID] = p.Rect.H

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 23

Tags 10

↑↓ moveenter openesc close