1package ui
2
3import (
4 "github.com/bnema/dumber/internal/domain/entity"
5 "github.com/bnema/dumber/internal/ui/input"
6)
7
8// Legend band geometry. The legend is a wide band anchored on the mode toaster,
9// not a full-width bar: its width follows the window up to a cap, and its
10// column count follows its width.
11const (
12 // legendMaxWidth caps the band width.
13 legendMaxWidth = 1100
14 // legendMinColumnWidth is the narrowest a column may get before the band
15 // drops a column and grows taller instead.
16 legendMinColumnWidth = 240
17 // legendComfortColumnWidth is the column width the band shrinks to when the
18 // mode has fewer groups than the width could hold columns.
19 legendComfortColumnWidth = 360
20 legendMaxColumns = 4
21 // legendColumnGap is the horizontal space between columns.
22 legendColumnGap = 12
23 // legendPanelPadding is the horizontal chrome (border and padding) around
24 // the columns.
25 legendPanelPadding = 28
26 // legendMaxHeightPercent bounds the legend's scrolling body, as a share of
27 // the content height.
28 legendMaxHeightPercent = 45
29 // legendChromeHeight is the vertical chrome (border, padding, hint line)
30 // around the scrolling body, subtracted from the height budget.
31 legendChromeHeight = 40
32 legendMinBodyHeight = 60
33 // modeToasterMargin separates the toaster from the window edges, and the
34 // legend band from the right edge.
35 modeToasterMargin = 8
36)
37
38// legendColumnCount returns how many columns of at least legendMinColumnWidth
39// fit a band of panelWidth, from 1 to legendMaxColumns.
40func legendColumnCount(panelWidth int) int {
41 columns := (panelWidth - legendPanelPadding + legendColumnGap) / (legendMinColumnWidth + legendColumnGap)
42 return min(max(columns, 1), legendMaxColumns)
43}
44
45// legendBandWidth returns the band width and column count for a legend of
46// groups groups when availableWidth pixels lie between the toaster's left edge
47// and the right margin. A band for few groups does not stretch over columns it
48// cannot fill.
49func legendBandWidth(availableWidth, groups int) (width, columns int) {
50 width = min(availableWidth, legendMaxWidth)
51 if width <= 0 || groups <= 0 {
52 return 0, 0
53 }
54 columns = legendColumnCount(width)
55 if groups < columns {
56 columns = groups
57 width = min(width, legendPanelPadding+columns*legendComfortColumnWidth+(columns-1)*legendColumnGap)
58 }
59 return width, columns
60}
61
62// legendBodyMaxHeight returns the height past which the legend body scrolls.
63func legendBodyMaxHeight(contentHeight int) int {
64 return max(contentHeight*legendMaxHeightPercent/100-legendChromeHeight, legendMinBodyHeight)
65}
66
67// distributeLegendGroups splits groups, given by their heights in display
68// order, into at most columns columns. Each column holds consecutive groups, so
69// the order reads down the first column, then the next, and no group is split.
70// Among all splits it picks the one with the shortest tallest column, then the
71// most even one; remaining ties go to the earliest split. The result lists
72// group indices per column and has no empty column.
73func distributeLegendGroups(heights []int, columns int) [][]int {
74 if len(heights) == 0 || columns < 1 {
75 return nil
76 }
77 columns = min(columns, len(heights))
78 best := legendSplit{tallest: -1}
79 cuts := make([]int, 0, columns-1)
80 var search func(start int)
81 search = func(start int) {
82 if len(cuts) == columns-1 {
83 candidate := scoreLegendSplit(heights, cuts)
84 if best.tallest < 0 || candidate.betterThan(best) {
85 best = candidate
86 }
87 return
88 }
89 // Leave one group for each column still to come.
90 remaining := columns - 1 - len(cuts)
91 for cut := start; cut <= len(heights)-remaining; cut++ {
92 cuts = append(cuts, cut)
93 search(cut + 1)
94 cuts = cuts[:len(cuts)-1]
95 }
96 }
97 search(1)
98
99 out := make([][]int, 0, columns)
100 first := 0
101 for _, cut := range append(best.cuts, len(heights)) {
102 column := make([]int, 0, cut-first)
103 for i := first; i < cut; i++ {
104 column = append(column, i)
105 }
106 out = append(out, column)
107 first = cut
108 }
109 return out
110}
111
112type legendSplit struct {
113 cuts []int
114 tallest int
115 sumSquares int
116}
117
118func (s legendSplit) betterThan(other legendSplit) bool {
119 if s.tallest != other.tallest {
120 return s.tallest < other.tallest
121 }
122 return s.sumSquares < other.sumSquares
123}
124
125func scoreLegendSplit(heights, cuts []int) legendSplit {
126 split := legendSplit{cuts: append([]int(nil), cuts...)}
127 first, sum := 0, 0
128 for _, cut := range append(append([]int(nil), cuts...), len(heights)) {
129 sum = 0
130 for _, h := range heights[first:cut] {
131 sum += h
132 }
133 split.tallest = max(split.tallest, sum)
134 split.sumSquares += sum * sum
135 first = cut
136 }
137 return split
138}
139
140// modeToasterVisible reports whether the toaster is shown for mode. The
141// indicator preference alone does not hide it while a legend can expand from
142// it: the toaster is the legend's header.
143func modeToasterVisible(mode input.Mode, cfg entity.WorkspaceStylingConfig) bool {
144 return mode != input.ModeNormal && (cfg.ModeIndicatorToasterEnabled || modeLegendEnabled(cfg))
145}
146
147// modeLegendEnabled reports whether a legend can expand from the toaster.
148// With the legend off the toaster is a plain indicator: no chevron, no toggle.
149func modeLegendEnabled(cfg entity.WorkspaceStylingConfig) bool {
150 return cfg.ModeLegend != "off"
151}
152
153// legendBodyShown reports whether the expanded legend is on screen. ready is
154// set once the legend's show delay has elapsed. A suspended legend (an in-page
155// interaction owns the keys) and a collapsed one stay hidden.
156func legendBodyShown(ready, collapsed, suspended bool) bool {
157 return ready && !collapsed && !suspended
158}
159
160// nextModeLegendCollapsed is the collapsed state after a click on the toaster.
161func nextModeLegendCollapsed(collapsed bool) bool {
162 return !collapsed
163}