cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / ui / rules / condfmt.go
condfmt.go 11.0K
1package rules23import (4 "strings"56 "src.rest/cat/012/internal/locale"7 "src.rest/cat/012/internal/sheet"8 "src.rest/cat/012/internal/ui/theme"9)1011// cfKind lists conditional formats.12type cfKind struct{}1314func (cfKind) title() string { return "Conditional format rules" }15func (cfKind) count(s *sheet.Sheet) int { return len(s.CondFormats()) }16func (cfKind) remove(s *sheet.Sheet, i int) { s.DeleteCondFormat(i) }1718func (cfKind) ordered() bool { return true }1920func (cfKind) command(action string) string { return "format.conditional_" + action }21func (cfKind) listHint() string { return "The first rule that matches a cell wins" }2223func (cfKind) move(s *sheet.Sheet, i, to int) bool {24 s.MoveCondFormat(i, to)25 return true26}2728// item is a rule's sample, its ranges and what it tests.29func (cfKind) item(th *theme.Theme, h Host, i, w int, sel bool) string {30 f := h.Sheet().CondFormats()[i]31 base := th.MenuBar32 if sel {33 base = th.MenuSelected34 }35 text := base.Render(" ") + sample(th, h, f, 4) + base.Render(" "+sheet.RangesText(f.Ranges)+" ")36 return spread(base, text+base.Render(f.Summary()), "", w)37}3839// sample draws w columns of a rule's look: its style on "123", its color40// scale, its data bar or its icons.41func sample(th *theme.Theme, h Host, f sheet.CondFormat, w int) string {42 switch {43 case f.IsBar():44 var b strings.Builder45 for _, n := range theme.BarCover(0.6, w) {46 b.WriteString(theme.Block(n))47 }48 return th.RuleText[f.Bar].Render(b.String())49 case f.IsIcons():50 n := len(f.Scale) + 151 gs := f.Icons.Glyphs(n)52 var b strings.Builder53 for k := range min(len(gs), w) {54 b.WriteString(th.RuleText[f.Icons.IconColor(k, n)].Render(gs[k]))55 }56 return b.String() + strings.Repeat(" ", max(w-len(gs), 0))57 case f.IsScale():58 return scaleBar(th, h, f.Scale, w)59 }60 // The fill spans the sample; text styles only its text, as in a cell.61 base := th.Rule(f.Style)62 s := th.Text(base, sheet.Style{Bold: f.Style.Bold, Italic: f.Style.Italic,63 Underline: f.Style.Underline, Strikethrough: f.Style.Strikethrough})64 text := theme.Center("123", w)65 lead := len(text) - len(strings.TrimLeft(text, " "))66 body := strings.TrimSpace(text)67 return base.Render(text[:lead]) + s.Render(body) + base.Render(text[lead+len(body):])68}6970// scaleBar draws a color scale's shades across w columns.71func scaleBar(th *theme.Theme, h Host, ps []sheet.ScalePoint, w int) string {72 var b strings.Builder73 for x := range w {74 pos := float64(x) / float64(max(w-1, 1))75 from, to := ps[0].Color, ps[len(ps)-1].Color76 if len(ps) == 3 {77 if pos <= 0.5 {78 from, to, pos = ps[0].Color, ps[1].Color, pos*279 } else {80 from, to, pos = ps[1].Color, ps[2].Color, (pos-0.5)*281 }82 }83 b.WriteString(th.ScaleFill(from, to, pos, h.Slot).Wrap(" "))84 }85 return b.String()86}8788// cfForm is a conditional format rule being edited.89type cfForm struct {90 i int91 ranges string92 scale int // formSingle, formScale, formBar or formIcons93 bar barForm94 op int // index in sheet.CondFormatOps95 args [2]string96 text int // index in sheet.Colors97 fill int98 bold bool99 italic bool100 under bool101 strike bool102 pts [3]cfPoint103 loc *locale.Locale // what args are typed in104}105106// cfPoint is a color scale point being edited: kind is an index in its107// row's kinds, where the midpoint's first is none, and color a named108// color less one, as points have no None.109type cfPoint struct {110 kind int111 value string112 color int113}114115// pointKinds are the kinds each point offers: the minimum, the116// midpoint (none first) and the maximum.117var pointKinds = [3][]sheet.PointKind{118 {sheet.PointMin, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile},119 {sheet.PointMin, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile}, // PointMin stands for none120 {sheet.PointMax, sheet.PointNumber, sheet.PointPercent, sheet.PointPercentile},121}122123func (cfKind) form(h Host, i int, sel sheet.Rect) form {124 f := &cfForm{i: i, ranges: sel.String(), fill: int(sheet.ColorGreen), loc: h.Sheet().Locale(), bar: newBarForm(),125 op: indexOf(sheet.CondFormatOps(), sheet.RuleNotEmpty),126 pts: [3]cfPoint{{color: int(sheet.ColorRed) - 1}, {value: "50", color: int(sheet.ColorYellow) - 1}, {color: int(sheet.ColorGreen) - 1}}}127 if i < 0 {128 return f129 }130 r := h.Sheet().CondFormats()[i]131 f.ranges = sheet.RangesText(r.Ranges)132 switch {133 case r.IsBar():134 f.scale = formBar135 f.bar.load(r)136 return f137 case r.IsIcons():138 f.scale = formIcons139 f.bar.load(r)140 return f141 }142 if r.IsScale() {143 f.scale = formScale144 for k, p := range r.Scale {145 slot := k146 if k == len(r.Scale)-1 {147 slot = 2148 }149 f.pts[slot] = cfPoint{kind: max(indexOf(pointKinds[slot], p.Kind), 0), value: sheet.LocalArg(p.Value, f.loc), color: int(p.Color) - 1}150 }151 return f152 }153 f.op, f.args = max(indexOf(sheet.CondFormatOps(), r.Op), 0), r.Args154 if !r.Op.OnText() {155 f.args = localArgs(r.Args, f.loc)156 }157 st := r.Style158 f.text, f.fill = int(st.Text), int(st.Fill)159 f.bold, f.italic, f.under, f.strike = st.Bold, st.Italic, st.Underline, st.Strikethrough160 return f161}162163func indexOf[T comparable](xs []T, x T) int {164 for i, y := range xs {165 if y == x {166 return i167 }168 }169 return -1170}171172func (f *cfForm) title() string {173 if f.i < 0 {174 return "Add conditional format"175 }176 return "Edit conditional format"177}178179// rule is the rule the form describes.180func (f *cfForm) rule() (sheet.CondFormat, error) {181 rs, ok := sheet.ParseRanges(f.ranges)182 if !ok && strings.TrimSpace(f.ranges) != "" {183 return sheet.CondFormat{}, errRange184 }185 r := sheet.CondFormat{Ranges: rs}186 loc := func(s string) string { return sheet.CanonicalArg(s, f.loc) }187 switch f.scale {188 case formBar:189 f.bar.barRule(&r, loc)190 return r, nil191 case formIcons:192 f.bar.iconRule(&r, loc)193 return r, nil194 }195 if f.scale == formScale {196 for k, p := range f.pts {197 if k == 1 && p.kind == 0 {198 continue // no midpoint199 }200 r.Scale = append(r.Scale, sheet.ScalePoint{Kind: pointKinds[k][p.kind], Value: sheet.CanonicalArg(strings.TrimSpace(p.value), f.loc), Color: sheet.Color(p.color + 1)})201 }202 return r, nil203 }204 r.Op = sheet.CondFormatOps()[f.op]205 r.Args = canonicalArgs(f.args, f.loc)206 if r.Op.OnText() {207 r.Args = [2]string{strings.TrimSpace(f.args[0]), strings.TrimSpace(f.args[1])}208 }209 r.Style = sheet.RuleStyle{Text: sheet.Color(f.text), Fill: sheet.Color(f.fill), Bold: f.bold,210 Italic: f.italic, Underline: f.under, Strikethrough: f.strike}211 return r, nil212}213214func (f *cfForm) save(h Host) error {215 r, err := f.rule()216 if err != nil {217 return err218 }219 return h.SaveFormat(f.i, r)220}221222func (f *cfForm) rows(th *theme.Theme, h Host) []row {223 rs := []row{224 {kind: rowText, label: "Apply to", text: &f.ranges, placeholder: "A2:A100", hint: "The ranges the rule applies to, e.g. A2:A100 or A2:A9,C2:C9"},225 {kind: rowChoice, label: "Format", choices: formatTitles, at: &f.scale, hint: "Color cells that meet a condition, every number along a scale, or draw bars or icons"},226 {kind: rowSep},227 }228 switch f.scale {229 case formScale:230 return append(rs, f.scaleRows(th, h)...)231 case formBar:232 return append(rs, f.bar.barRows(th, func(w int) string {233 return barSample(th, sheet.Color(f.bar.color+1), min(w, 24))234 })...)235 case formIcons:236 return append(rs, f.bar.iconRows(func(int) string {237 return iconSample(th, sheet.IconSets()[f.bar.set], f.bar.icons(), f.bar.reverse)238 })...)239 }240 op := sheet.CondFormatOps()[f.op]241 rs = append(rs, row{kind: rowChoice, label: "Format if", choices: opTitles(), at: &f.op, hint: "The condition cells must meet"})242 switch op.Args() {243 case 1:244 hint, holder := argHint(op)245 rs = append(rs, row{kind: rowText, label: "Value", text: &f.args[0], placeholder: holder, hint: hint})246 case 2:247 rs = append(rs, row{kind: rowText, label: "Between", text: &f.args[0], placeholder: "lowest", hint: "The lowest value"},248 row{kind: rowText, label: "And", text: &f.args[1], placeholder: "highest", hint: "The highest value"})249 }250 swatch := func(fill bool) func(int) string {251 return func(i int) string {252 if i == 0 {253 return th.MenuBar.Render(" ")254 }255 if fill {256 return th.RuleFill[i].Render(" ")257 }258 return th.RuleText[i].Render("Aa")259 }260 }261 rs = append(rs, row{kind: rowSep},262 row{kind: rowChoice, label: "Text color", choices: colorTitles(), at: &f.text, swatch: swatch(false), hint: "The text's color, from the terminal's palette"},263 row{kind: rowChoice, label: "Fill", choices: colorTitles(), at: &f.fill, swatch: swatch(true), hint: "The cell's background, from the terminal's palette"},264 row{kind: rowCheck, label: "Bold", on: &f.bold, hint: "Bold text"},265 row{kind: rowCheck, label: "Italic", on: &f.italic, hint: "Italic text"},266 row{kind: rowCheck, label: "Underline", on: &f.under, hint: "Underlined text"},267 row{kind: rowCheck, label: "Strikethrough", on: &f.strike, hint: "Struck through text"},268 row{kind: rowSep},269 row{kind: rowPreview, label: "Preview", preview: func(int) string {270 r, _ := f.rule()271 return sample(th, h, r, 12)272 }})273 return rs274}275276// scaleRows are the rows of a color scale: each point's kind, value and277// color, and a preview.278func (f *cfForm) scaleRows(th *theme.Theme, h Host) []row {279 var rs []row280 names := [3]string{"Minpoint", "Midpoint", "Maxpoint"}281 for k := range f.pts {282 p := &f.pts[k]283 kinds := make([]string, len(pointKinds[k]))284 for j, pk := range pointKinds[k] {285 kinds[j] = pk.Title()286 }287 if k == 1 {288 kinds[0] = "None"289 }290 rs = append(rs, row{kind: rowChoice, label: names[k], choices: kinds, at: &p.kind, hint: "Where the point's color goes among the values"})291 if k == 1 && p.kind == 0 {292 continue293 }294 if pointKinds[k][p.kind].TakesValue() {295 rs = append(rs, row{kind: rowText, label: " Value", text: &p.value, placeholder: "50", hint: "The point's number, percent or percentile"})296 }297 rs = append(rs, row{kind: rowChoice, label: " Color", choices: colorTitles()[1:], at: &p.color, swatch: func(i int) string {298 return th.RuleFill[i+1].Render(" ")299 }, hint: "The point's color, from the terminal's palette"})300 }301 return append(rs, row{kind: rowSep}, row{kind: rowPreview, label: "Preview", preview: func(w int) string {302 r, _ := f.rule()303 return scaleBar(th, h, r.Scale, min(w, 24))304 }})305}306307// argHint is the hint and placeholder of a condition's value.308func argHint(op sheet.RuleOp) (hint, holder string) {309 switch op {310 case sheet.RuleFormula:311 return "TRUE for cells to format, written for the first cell: =$C2>100", "=$C2>100"312 case sheet.RuleDateIs, sheet.RuleDateBefore, sheet.RuleDateAfter:313 return "A date, or a period: " + strings.Join(sheet.Periods(), ", "), "2026-09-30 or this week"314 case sheet.RuleTop, sheet.RuleBottom:315 return "How many of the highest or lowest values, 1 to 1000", "10"316 case sheet.RuleTopPercent, sheet.RuleBottomPercent:317 return "What percent of the highest or lowest values, 1 to 100", "10"318 }319 return "The value to compare with, or a formula starting with =", "value or =formula"320}321322func opTitles() []string {323 ops := sheet.CondFormatOps()324 out := make([]string, len(ops))325 for i, op := range ops {326 out[i] = op.Title()327 }328 return out329}330331func colorTitles() []string {332 cs := sheet.Colors()333 out := make([]string, len(cs))334 for i, c := range cs {335 out[i] = c.Title()336 }337 return out338}