charts.go was 772 lines. The selected chart (chartSel) and the chart editor move to their own files. chartSel's mouse handling (cognitive complexity 34) splits into press, motion, hover shape and release, and its Enter and Del keys go through the chart.edit and chart.delete commands. Drawing a chart's rows needs only the theme, so chartRow and chartRole become functions of it. Imports are regrouped where earlier moves ran them together.
- commit
- a1b2028215cf8444df652b45bd2e162a9a4fad1b
- parent
- 4254f682f48d32a3700079238c7dc7c92f64ba9f
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 234 | ++++++++++++++++ |
| M | internal/ | 452 | +----------------------------- |
| A | internal/ | 239 | ++++++++++++++++ |
| M | internal/ | 3 | +- |
| M | internal/ | 3 | +- |
| M | internal/ | 3 | +- |
| M | internal/ | 1 | + |
| M | internal/ | 3 | +- |
| M | internal/ | 3 | +- |
| M | internal/ | 3 | +- |
@@ -0,0 +1,234 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// chartEditor is the chart editor: a bar on the context line, in the14+// spirit of the find bar, with the type as a row of chips and the options15+// as toggles. Changes show at once; Enter keeps them and Esc undoes them,16+// removing a chart that was just inserted.17+type chartEditor struct {18+ i int19+ start int // the sheet's state before editing, to undo back to20+ isNew bool21+}22+23+// openChartEditor edits chart i. start is the sheet's state to return to24+// on Esc.25+func (m *Model) openChartEditor(i int, isNew bool, start int) {26+ if i < 0 || i >= len(m.sheet.Charts()) {27+ return28+ }29+ m.charts.last = i30+ m.openOverlay(&chartEditor{i: i, start: start, isNew: isNew})31+}32+33+func (e *chartEditor) indicator() string { return "CHART" }34+35+func (e *chartEditor) layout(*Model) []box { return nil }36+37+func (e *chartEditor) chart(m *Model) sheet.Chart { return m.sheet.Charts()[e.i] }38+39+// set applies a change to the chart as its own undo step.40+func (e *chartEditor) set(m *Model, fn func(c *sheet.Chart)) {41+ c := e.chart(m)42+ fn(&c)43+ m.sheet.SetChart(e.i, c, "edit chart")44+ m.changed = m.sheet.StateID() != m.saved45+}46+47+func (e *chartEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {48+ if e.i >= len(m.sheet.Charts()) {49+ m.closeOverlay()50+ return nil51+ }52+ switch key := strings.ToLower(k.String()); key {53+ case "enter":54+ m.selectChart(e.i)55+ if e.isNew {56+ m.note = "Inserted a chart of " + e.chart(m).Data.String()57+ }58+ case "esc":59+ for m.sheet.StateID() != e.start && m.sheet.CanUndo() {60+ m.sheet.Undo()61+ }62+ m.changed = m.sheet.StateID() != m.saved63+ m.closeOverlay()64+ m.charts.last = -165+ case "left", "right", "shift+tab", "tab":66+ d := 167+ if key == "left" || key == "shift+tab" {68+ d = -169+ }70+ e.set(m, func(c *sheet.Chart) {71+ n := len(sheet.ChartTypes)72+ c.Type = sheet.ChartTypes[((int(c.Type)+d)%n+n)%n]73+ })74+ case "1", "2", "3", "4":75+ e.set(m, func(c *sheet.Chart) { c.Type = sheet.ChartTypes[key[0]-'1'] })76+ case editorSwitch:77+ e.set(m, func(c *sheet.Chart) { c.ByRow = !c.ByRow })78+ case editorHeader:79+ e.set(m, func(c *sheet.Chart) { c.Header = !c.Header })80+ case editorLabels:81+ e.set(m, func(c *sheet.Chart) { c.Labels = !c.Labels })82+ case editorRange:83+ e.editRange(m)84+ case editorTitle:85+ e.editTitle(m)86+ }87+ return nil88+}89+90+// reopen returns to the editor after a prompt.91+func (e *chartEditor) reopen(m *Model) {92+ m.openOverlay(e)93+}94+95+// editRange asks for the data range by pointing, starting from the96+// current one.97+func (e *chartEditor) editRange(m *Model) {98+ d := e.chart(m).Data99+ m.closeOverlay()100+ m.cur, m.ext, m.selecting, m.whole = d.From, d.To, true, wholeNone101+ m.openRange("Chart data:", func(m *Model, r sheet.Rect) tea.Cmd {102+ m.clearSelection()103+ e.set(m, func(c *sheet.Chart) { c.Data = r })104+ e.reopen(m)105+ return nil106+ })107+ m.prompt.onCancel = func(m *Model) {108+ m.clearSelection()109+ e.reopen(m)110+ }111+}112+113+func (e *chartEditor) editTitle(m *Model) {114+ title := e.chart(m).Title115+ m.closeOverlay()116+ m.openText("Chart title:", title, func(m *Model, text string) tea.Cmd {117+ e.set(m, func(c *sheet.Chart) { c.Title = text })118+ e.reopen(m)119+ return nil120+ })121+ m.prompt.onCancel = e.reopen122+}123+124+// editorPart is a piece of the editor bar: a type chip or a toggle.125+type editorPart struct {126+ text string127+ key string // the key it stands for128+}129+130+func (e *chartEditor) parts(m *Model) []editorPart {131+ c := e.chart(m)132+ chip := func(on bool, name, key string) editorPart {133+ style := m.th.Muted134+ if on {135+ style = m.th.MenuSelected136+ }137+ return editorPart{text: style.Render(" " + name + " "), key: key}138+ }139+ var parts []editorPart140+ for i, t := range sheet.ChartTypes {141+ parts = append(parts, chip(t == c.Type, t.Title(), strconv.Itoa(i+1)))142+ }143+ series := "Series in columns"144+ if c.ByRow {145+ series = "Series in rows"146+ }147+ header, labels := "Header row", "Labels in column"148+ if c.ByRow {149+ header, labels = "Header column", "Labels in row"150+ }151+ return append(parts,152+ editorPart{text: m.th.Key.Render(c.Data.String()), key: editorRange},153+ chip(true, series, editorSwitch),154+ chip(c.Header, header, editorHeader),155+ chip(c.Labels, labels, editorLabels))156+}157+158+// spans lays the parts out on the context line, dropping options from the159+// right on narrow screens, and returns each part's x.160+func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {161+ parts := e.parts(m)162+ for {163+ xs := make([]int, len(parts))164+ x := 0165+ for i, p := range parts {166+ if i == len(sheet.ChartTypes) {167+ x += 2 // a wider gap between the types and the options168+ }169+ xs[i] = x170+ x += ansi.StringWidth(p.text) + 1171+ }172+ if x-1 <= m.width || len(parts) <= len(sheet.ChartTypes) {173+ return parts, xs174+ }175+ parts = parts[:len(parts)-1]176+ }177+}178+179+func (e *chartEditor) contextLine(m *Model) (string, string) {180+ if e.i >= len(m.sheet.Charts()) {181+ return "", ""182+ }183+ parts, xs := e.spans(m)184+ var b strings.Builder185+ for i, p := range parts {186+ b.WriteString(strings.Repeat(" ", xs[i]-ansi.StringWidth(b.String())))187+ b.WriteString(p.text)188+ }189+ return b.String(), ""190+}191+192+func (e *chartEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {193+ if ev.kind != mousePress {194+ return nil195+ }196+ if ev.y != contextLine {197+ // A click elsewhere keeps the changes and acts as usual.198+ m.closeOverlay()199+ return m.handlePress(tea.Mouse{X: ev.x, Y: ev.y, Button: ev.button})200+ }201+ parts, xs := e.spans(m)202+ for i, p := range parts {203+ if ev.x >= xs[i] && ev.x < xs[i]+ansi.StringWidth(p.text) {204+ return e.key(m, tea.KeyPressMsg{Code: rune(p.key[0]), Text: p.key})205+ }206+ }207+ return nil208+}209+210+func (e *chartEditor) status(m *Model) (string, string) {211+ pairs := []string{"←/→", "type", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}212+ for {213+ keys := m.th.KeyHints(pairs...)214+ if ansi.StringWidth(keys) <= m.width || len(pairs) <= 4 {215+ return "", keys216+ }217+ pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...) // keep Enter and Esc218+ }219+}220+221+// contextLiner is an overlay drawn on the context line, like the chart222+// editor.223+type contextLiner interface {224+ contextLine(m *Model) (left, right string)225+}226+227+// Editor keys, also the toggles' mouse targets.228+const (229+ editorSwitch = "s"230+ editorHeader = "h"231+ editorLabels = "l"232+ editorRange = "r"233+ editorTitle = "t"234+)@@ -10,6 +10,7 @@ import (
1010 1111 "github.com/FelineStateMachine/012/internal/chart"1212 "github.com/FelineStateMachine/012/internal/sheet"13+ "github.com/FelineStateMachine/012/internal/ui/theme"1314 )1415 1516 // Charts follow Sheets: Insert > Chart charts the selection (or the block@@ -238,7 +239,7 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
238239 lines = append(lines, border.Render("┌─")+m.th.Title.Render(title)+239240 border.Render(strings.Repeat("─", max(c.W-3-ansi.StringWidth(title), 0))+"┐"))240241 for y := range h {241- lines = append(lines, border.Render("│")+" "+m.chartRow(g, y, firstImageID+i, o.Image)+" "+border.Render("│"))242+ lines = append(lines, border.Render("│")+" "+chartRow(&m.th, g, y, firstImageID+i, o.Image)+" "+border.Render("│"))242243 }243244 corner := "┘"244245 if selected {@@ -255,7 +256,7 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
255256 256257 // chartRow renders row y of a drawn chart with the theme; with images,257258 // the plot area is the image's placeholders.258-func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {259+func chartRow(th *theme.Theme, g *chart.Grid, y, imageID int, image bool) string {259260 var b, run strings.Builder260261 var style lipgloss.Style261262 styled := false@@ -277,15 +278,15 @@ func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {
277278 text := c.Text278279 switch {279280 case image && y >= g.Plot.Min.Y && y < g.Plot.Max.Y && x >= g.Plot.Min.X && x < g.Plot.Max.X:280- key, st = 1, m.th.ImageID(imageID)281+ key, st = 1, th.ImageID(imageID)281282 text = chart.Placeholder(y-g.Plot.Min.Y, x-g.Plot.Min.X)282283 case c.Fg == chart.None && c.Bg == chart.None:283284 key, isStyled = 2, false284285 default:285286 key = 3 + int(c.Fg)*32 + int(c.Bg)286- st = m.chartRole(c.Fg)287+ st = chartRole(th, c.Fg)287288 if c.Bg >= chart.Series {288- st = st.Inherit(m.th.SeriesBg[int(c.Bg-chart.Series)%chart.Colors])289+ st = st.Inherit(th.SeriesBg[int(c.Bg-chart.Series)%chart.Colors])289290 }290291 }291292 if key != prev {@@ -298,18 +299,18 @@ func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {
298299 return b.String()299300 }300301 301-func (m *Model) chartRole(r chart.Role) lipgloss.Style {302+func chartRole(th *theme.Theme, r chart.Role) lipgloss.Style {302303 switch {303304 case r == chart.Axis:304- return m.th.ChartAxis305+ return th.ChartAxis305306 case r == chart.Label:306- return m.th.ChartLabel307+ return th.ChartLabel307308 case r == chart.Muted:308- return m.th.Muted309+ return th.Muted309310 case r >= chart.Series:310- return m.th.Series[int(r-chart.Series)%chart.Colors]311+ return th.Series[int(r-chart.Series)%chart.Colors]311312 }312- return m.th.Cell313+ return th.Cell313314 }314315 315316 // chartClick handles a press on the grid in READY: pressing a chart@@ -346,432 +347,3 @@ func (m *Model) setShape(shape string) tea.Cmd {
346347 m.mouse.shape = shape347348 return tea.Raw(ansi.SetPointerShape(shape))348349 }349-350-// chartSel is a selected chart. Arrows move it a cell at a time, Shift351-// and arrows resize it, Enter edits it and Del deletes it; the mouse352-// drags it or its corner. Other keys deselect it and act as usual.353-type chartSel struct {354- i int355- drag int // chartDragNone, chartDragMove or chartDragResize356- grabX int // where the chart was grabbed, relative to its corner357- grabY int358- preview *sheet.Chart // the chart while being dragged359-}360-361-const (362- chartDragNone = iota363- chartDragMove364- chartDragResize365-)366-367-func (s *chartSel) indicator() string { return "CHART" }368-func (s *chartSel) layout(*Model) []box {369- return nil // drawn with the other charts, see chartBoxes370-}371-372-func (s *chartSel) chart(m *Model) (sheet.Chart, bool) {373- charts := m.sheet.Charts()374- if s.i < 0 || s.i >= len(charts) {375- return sheet.Chart{}, false376- }377- return charts[s.i], true378-}379-380-func (s *chartSel) key(m *Model, k tea.KeyPressMsg) tea.Cmd {381- c, ok := s.chart(m)382- if !ok {383- m.closeOverlay()384- return nil385- }386- moved := c387- switch k.String() {388- case "esc":389- m.closeOverlay()390- return nil391- case "enter":392- m.openChartEditor(s.i, false, m.sheet.StateID())393- return nil394- case "delete", "backspace":395- m.deleteChart(s.i)396- return nil397- case "tab":398- m.selectChart((s.i + 1) % len(m.sheet.Charts()))399- return nil400- case "up":401- moved.At.Row--402- case "down":403- moved.At.Row++404- case "left":405- moved.At.Col--406- case "right":407- moved.At.Col++408- case "shift+up":409- moved.H--410- case "shift+down":411- moved.H++412- case "shift+left":413- moved.W -= 2414- case "shift+right":415- moved.W += 2416- default:417- m.closeOverlay()418- return m.handleKey(k)419- }420- label := "move chart"421- if moved.W != c.W || moved.H != c.H {422- label = "resize chart"423- }424- m.sheet.SetChart(s.i, moved, label)425- m.changed = true426- m.showChart(s.i)427- return nil428-}429-430-// press starts a drag at x, y: the corner resizes, anywhere else moves.431-func (s *chartSel) press(m *Model, x, y int) {432- c, ok := s.chart(m)433- if !ok {434- return435- }436- cx, cy := m.chartScreen(c)437- s.grabX, s.grabY = x-cx, y-cy438- s.drag = chartDragMove439- if x >= cx+c.W-2 && y == cy+c.H-1 {440- s.drag = chartDragResize441- }442- s.preview = &c443-}444-445-func (s *chartSel) mouse(m *Model, e mouseEvent) tea.Cmd {446- switch e.kind {447- case mousePress:448- i := m.chartAt(e.x, e.y)449- switch {450- case i < 0:451- m.closeOverlay()452- return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})453- case e.button == tea.MouseRight:454- m.selectChart(i)455- m.showContextMenu(chartMenu, e.x, e.y+1)456- case e.button == tea.MouseLeft:457- if i != s.i {458- m.selectChart(i)459- s = m.overlay.(*chartSel)460- }461- s.press(m, e.x, e.y)462- }463- case mouseMotion:464- if s.drag == chartDragNone || s.preview == nil {465- shape := "default"466- if i := m.chartAt(e.x, e.y); i >= 0 {467- shape = "move"468- if c := m.displayCharts()[i]; i == s.i {469- cx, cy := m.chartScreen(c)470- if e.x >= cx+c.W-2 && e.y == cy+c.H-1 {471- shape = "nwse-resize"472- }473- }474- }475- return m.setShape(shape)476- }477- p := *s.preview478- if s.drag == chartDragResize {479- cx, cy := m.chartScreen(p)480- p.W = clamp(e.x-cx+1, sheet.MinChartW, sheet.MaxChartW)481- p.H = clamp(e.y-cy+1, sheet.MinChartH, sheet.MaxChartH)482- } else {483- p.At = m.chartCellAt(e.x-s.grabX, e.y-s.grabY)484- }485- s.preview = &p486- case mouseRelease:487- if s.preview != nil && s.drag != chartDragNone {488- label := "move chart"489- if s.drag == chartDragResize {490- label = "resize chart"491- }492- before := m.sheet.StateID()493- m.sheet.SetChart(s.i, *s.preview, label)494- if m.sheet.StateID() != before {495- m.changed = true496- }497- }498- s.drag, s.preview = chartDragNone, nil499- case mouseWheel:500- m.handleWheel(tea.Mouse{X: e.x, Y: e.y, Button: e.button})501- }502- return nil503-}504-505-// chartCellAt returns the cell a chart's corner snaps to when dragged to506-// screen position x, y, which may be past the grid's edges.507-func (g *grid) chartCellAt(x, y int) sheet.Addr {508- a := sheet.Addr{Row: max(g.top+y-gridTop, 0), Col: g.left}509- switch {510- case x >= rowHdrW:511- if col, _, ok := g.colSpan(x); ok {512- a.Col = col513- } else {514- a.Col = g.left + g.visibleCols(g.left) - 1515- }516- default:517- for cx := rowHdrW; cx > x && a.Col > 0; {518- a.Col--519- cx -= g.sheet.ColWidth(a.Col)520- }521- }522- return clampAddr(a)523-}524-525-func (s *chartSel) status(m *Model) (string, string) {526- c, ok := s.chart(m)527- if !ok {528- return "", ""529- }530- desc := m.th.Muted.Render("Chart of " + c.Data.String())531- pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}532- for {533- keys := m.th.KeyHints(pairs...)534- switch {535- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:536- return desc, keys537- case desc != "":538- desc = ""539- case len(pairs) > 4:540- pairs = append(pairs[:len(pairs)-4], pairs[len(pairs)-2:]...) // keep Esc541- default:542- return "", keys543- }544- }545-}546-547-// contextLine says what's selected and how to change it.548-func (s *chartSel) contextLine(m *Model) (string, string) {549- c, ok := s.chart(m)550- if !ok {551- return "", ""552- }553- return m.th.Key.Render(c.Type.Title()+" chart") + m.th.Muted.Render(" of ") + c.Data.String() +554- m.th.Muted.Render(" drag to move, drag the corner to resize"), ""555-}556-557-// chartEditor is the chart editor: a bar on the context line, in the558-// spirit of the find bar, with the type as a row of chips and the options559-// as toggles. Changes show at once; Enter keeps them and Esc undoes them,560-// removing a chart that was just inserted.561-type chartEditor struct {562- i int563- start int // the sheet's state before editing, to undo back to564- isNew bool565-}566-567-// openChartEditor edits chart i. start is the sheet's state to return to568-// on Esc.569-func (m *Model) openChartEditor(i int, isNew bool, start int) {570- if i < 0 || i >= len(m.sheet.Charts()) {571- return572- }573- m.charts.last = i574- m.openOverlay(&chartEditor{i: i, start: start, isNew: isNew})575-}576-577-func (e *chartEditor) indicator() string { return "CHART" }578-func (e *chartEditor) layout(*Model) []box { return nil }579-580-func (e *chartEditor) chart(m *Model) sheet.Chart { return m.sheet.Charts()[e.i] }581-582-// set applies a change to the chart as its own undo step.583-func (e *chartEditor) set(m *Model, fn func(c *sheet.Chart)) {584- c := e.chart(m)585- fn(&c)586- m.sheet.SetChart(e.i, c, "edit chart")587- m.changed = m.sheet.StateID() != m.saved588-}589-590-// Editor keys, also the toggles' mouse targets.591-const (592- editorSwitch = "s"593- editorHeader = "h"594- editorLabels = "l"595- editorRange = "r"596- editorTitle = "t"597-)598-599-func (e *chartEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {600- if e.i >= len(m.sheet.Charts()) {601- m.closeOverlay()602- return nil603- }604- switch key := strings.ToLower(k.String()); key {605- case "enter":606- m.selectChart(e.i)607- if e.isNew {608- m.note = "Inserted a chart of " + e.chart(m).Data.String()609- }610- case "esc":611- for m.sheet.StateID() != e.start && m.sheet.CanUndo() {612- m.sheet.Undo()613- }614- m.changed = m.sheet.StateID() != m.saved615- m.closeOverlay()616- m.charts.last = -1617- case "left", "right", "shift+tab", "tab":618- d := 1619- if key == "left" || key == "shift+tab" {620- d = -1621- }622- e.set(m, func(c *sheet.Chart) {623- n := len(sheet.ChartTypes)624- c.Type = sheet.ChartTypes[((int(c.Type)+d)%n+n)%n]625- })626- case "1", "2", "3", "4":627- e.set(m, func(c *sheet.Chart) { c.Type = sheet.ChartTypes[key[0]-'1'] })628- case editorSwitch:629- e.set(m, func(c *sheet.Chart) { c.ByRow = !c.ByRow })630- case editorHeader:631- e.set(m, func(c *sheet.Chart) { c.Header = !c.Header })632- case editorLabels:633- e.set(m, func(c *sheet.Chart) { c.Labels = !c.Labels })634- case editorRange:635- e.editRange(m)636- case editorTitle:637- e.editTitle(m)638- }639- return nil640-}641-642-// reopen returns to the editor after a prompt.643-func (e *chartEditor) reopen(m *Model) {644- m.openOverlay(e)645-}646-647-// editRange asks for the data range by pointing, starting from the648-// current one.649-func (e *chartEditor) editRange(m *Model) {650- d := e.chart(m).Data651- m.closeOverlay()652- m.cur, m.ext, m.selecting, m.whole = d.From, d.To, true, wholeNone653- m.openRange("Chart data:", func(m *Model, r sheet.Rect) tea.Cmd {654- m.clearSelection()655- e.set(m, func(c *sheet.Chart) { c.Data = r })656- e.reopen(m)657- return nil658- })659- m.prompt.onCancel = func(m *Model) {660- m.clearSelection()661- e.reopen(m)662- }663-}664-665-func (e *chartEditor) editTitle(m *Model) {666- title := e.chart(m).Title667- m.closeOverlay()668- m.openText("Chart title:", title, func(m *Model, text string) tea.Cmd {669- e.set(m, func(c *sheet.Chart) { c.Title = text })670- e.reopen(m)671- return nil672- })673- m.prompt.onCancel = e.reopen674-}675-676-// editorPart is a piece of the editor bar: a type chip or a toggle.677-type editorPart struct {678- text string679- key string // the key it stands for680-}681-682-func (e *chartEditor) parts(m *Model) []editorPart {683- c := e.chart(m)684- chip := func(on bool, name, key string) editorPart {685- style := m.th.Muted686- if on {687- style = m.th.MenuSelected688- }689- return editorPart{text: style.Render(" " + name + " "), key: key}690- }691- var parts []editorPart692- for i, t := range sheet.ChartTypes {693- parts = append(parts, chip(t == c.Type, t.Title(), strconv.Itoa(i+1)))694- }695- series := "Series in columns"696- if c.ByRow {697- series = "Series in rows"698- }699- header, labels := "Header row", "Labels in column"700- if c.ByRow {701- header, labels = "Header column", "Labels in row"702- }703- return append(parts,704- editorPart{text: m.th.Key.Render(c.Data.String()), key: editorRange},705- chip(true, series, editorSwitch),706- chip(c.Header, header, editorHeader),707- chip(c.Labels, labels, editorLabels))708-}709-710-// spans lays the parts out on the context line, dropping options from the711-// right on narrow screens, and returns each part's x.712-func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {713- parts := e.parts(m)714- for {715- xs := make([]int, len(parts))716- x := 0717- for i, p := range parts {718- if i == len(sheet.ChartTypes) {719- x += 2 // a wider gap between the types and the options720- }721- xs[i] = x722- x += ansi.StringWidth(p.text) + 1723- }724- if x-1 <= m.width || len(parts) <= len(sheet.ChartTypes) {725- return parts, xs726- }727- parts = parts[:len(parts)-1]728- }729-}730-731-func (e *chartEditor) contextLine(m *Model) (string, string) {732- if e.i >= len(m.sheet.Charts()) {733- return "", ""734- }735- parts, xs := e.spans(m)736- var b strings.Builder737- for i, p := range parts {738- b.WriteString(strings.Repeat(" ", xs[i]-ansi.StringWidth(b.String())))739- b.WriteString(p.text)740- }741- return b.String(), ""742-}743-744-func (e *chartEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {745- if ev.kind != mousePress {746- return nil747- }748- if ev.y != contextLine {749- // A click elsewhere keeps the changes and acts as usual.750- m.closeOverlay()751- return m.handlePress(tea.Mouse{X: ev.x, Y: ev.y, Button: ev.button})752- }753- parts, xs := e.spans(m)754- for i, p := range parts {755- if ev.x >= xs[i] && ev.x < xs[i]+ansi.StringWidth(p.text) {756- return e.key(m, tea.KeyPressMsg{Code: rune(p.key[0]), Text: p.key})757- }758- }759- return nil760-}761-762-func (e *chartEditor) status(m *Model) (string, string) {763- pairs := []string{"←/→", "type", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}764- for {765- keys := m.th.KeyHints(pairs...)766- if ansi.StringWidth(keys) <= m.width || len(pairs) <= 4 {767- return "", keys768- }769- pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...) // keep Enter and Esc770- }771-}772-773-// contextLiner is an overlay drawn on the context line, like the chart774-// editor.775-type contextLiner interface {776- contextLine(m *Model) (left, right string)777-}@@ -0,0 +1,239 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+ "github.com/charmbracelet/x/ansi"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// chartSel is a selected chart. Arrows move it a cell at a time, Shift11+// and arrows resize it, Enter edits it and Del deletes it; the mouse12+// drags it or its corner. Other keys deselect it and act as usual.13+type chartSel struct {14+ i int15+ drag int // chartDragNone, chartDragMove or chartDragResize16+ grabX int // where the chart was grabbed, relative to its corner17+ grabY int18+ preview *sheet.Chart // the chart while being dragged19+}20+21+func (s *chartSel) indicator() string { return "CHART" }22+23+func (s *chartSel) layout(*Model) []box {24+ return nil // drawn with the other charts, see chartBoxes25+}26+27+func (s *chartSel) chart(m *Model) (sheet.Chart, bool) {28+ charts := m.sheet.Charts()29+ if s.i < 0 || s.i >= len(charts) {30+ return sheet.Chart{}, false31+ }32+ return charts[s.i], true33+}34+35+func (s *chartSel) key(m *Model, k tea.KeyPressMsg) tea.Cmd {36+ c, ok := s.chart(m)37+ if !ok {38+ m.closeOverlay()39+ return nil40+ }41+ moved := c42+ switch k.String() {43+ case "esc":44+ m.closeOverlay()45+ return nil46+ case "enter":47+ return m.runCommand("chart.edit")48+ case "delete", "backspace":49+ return m.runCommand("chart.delete")50+ case "tab":51+ m.selectChart((s.i + 1) % len(m.sheet.Charts()))52+ return nil53+ case "up":54+ moved.At.Row--55+ case "down":56+ moved.At.Row++57+ case "left":58+ moved.At.Col--59+ case "right":60+ moved.At.Col++61+ case "shift+up":62+ moved.H--63+ case "shift+down":64+ moved.H++65+ case "shift+left":66+ moved.W -= 267+ case "shift+right":68+ moved.W += 269+ default:70+ m.closeOverlay()71+ return m.handleKey(k)72+ }73+ label := "move chart"74+ if moved.W != c.W || moved.H != c.H {75+ label = "resize chart"76+ }77+ m.sheet.SetChart(s.i, moved, label)78+ m.changed = true79+ m.showChart(s.i)80+ return nil81+}82+83+// press starts a drag at x, y: the corner resizes, anywhere else moves.84+func (s *chartSel) press(m *Model, x, y int) {85+ c, ok := s.chart(m)86+ if !ok {87+ return88+ }89+ cx, cy := m.chartScreen(c)90+ s.grabX, s.grabY = x-cx, y-cy91+ s.drag = chartDragMove92+ if x >= cx+c.W-2 && y == cy+c.H-1 {93+ s.drag = chartDragResize94+ }95+ s.preview = &c96+}97+98+func (s *chartSel) mouse(m *Model, e mouseEvent) tea.Cmd {99+ switch e.kind {100+ case mousePress:101+ return s.pressAt(m, e)102+ case mouseMotion:103+ return s.motion(m, e)104+ case mouseRelease:105+ s.release(m)106+ case mouseWheel:107+ m.handleWheel(tea.Mouse{X: e.x, Y: e.y, Button: e.button})108+ }109+ return nil110+}111+112+// pressAt selects the chart pressed and starts dragging it, or opens its113+// menu; pressing off the charts deselects and acts as usual.114+func (s *chartSel) pressAt(m *Model, e mouseEvent) tea.Cmd {115+ i := m.chartAt(e.x, e.y)116+ switch {117+ case i < 0:118+ m.closeOverlay()119+ return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})120+ case e.button == tea.MouseRight:121+ m.selectChart(i)122+ m.showContextMenu(chartMenu, e.x, e.y+1)123+ case e.button == tea.MouseLeft:124+ if i != s.i {125+ m.selectChart(i)126+ s = m.overlay.(*chartSel)127+ }128+ s.press(m, e.x, e.y)129+ }130+ return nil131+}132+133+// motion moves or resizes the chart being dragged, or else sets the134+// pointer shape for what's under the mouse.135+func (s *chartSel) motion(m *Model, e mouseEvent) tea.Cmd {136+ if s.drag == chartDragNone || s.preview == nil {137+ return m.setShape(s.hoverShape(m, e.x, e.y))138+ }139+ p := *s.preview140+ if s.drag == chartDragResize {141+ cx, cy := m.chartScreen(p)142+ p.W = clamp(e.x-cx+1, sheet.MinChartW, sheet.MaxChartW)143+ p.H = clamp(e.y-cy+1, sheet.MinChartH, sheet.MaxChartH)144+ } else {145+ p.At = m.chartCellAt(e.x-s.grabX, e.y-s.grabY)146+ }147+ s.preview = &p148+ return nil149+}150+151+// hoverShape is the pointer shape at x, y: a move cursor over charts,152+// and a resize one over the selected chart's corner.153+func (s *chartSel) hoverShape(m *Model, x, y int) string {154+ i := m.chartAt(x, y)155+ if i < 0 {156+ return "default"157+ }158+ if c := m.displayCharts()[i]; i == s.i {159+ cx, cy := m.chartScreen(c)160+ if x >= cx+c.W-2 && y == cy+c.H-1 {161+ return "nwse-resize"162+ }163+ }164+ return "move"165+}166+167+// release drops the chart being dragged where it is, as one undo step.168+func (s *chartSel) release(m *Model) {169+ if s.preview != nil && s.drag != chartDragNone {170+ label := "move chart"171+ if s.drag == chartDragResize {172+ label = "resize chart"173+ }174+ before := m.sheet.StateID()175+ m.sheet.SetChart(s.i, *s.preview, label)176+ if m.sheet.StateID() != before {177+ m.changed = true178+ }179+ }180+ s.drag, s.preview = chartDragNone, nil181+}182+183+// chartCellAt returns the cell a chart's corner snaps to when dragged to184+// screen position x, y, which may be past the grid's edges.185+func (g *grid) chartCellAt(x, y int) sheet.Addr {186+ a := sheet.Addr{Row: max(g.top+y-gridTop, 0), Col: g.left}187+ switch {188+ case x >= rowHdrW:189+ if col, _, ok := g.colSpan(x); ok {190+ a.Col = col191+ } else {192+ a.Col = g.left + g.visibleCols(g.left) - 1193+ }194+ default:195+ for cx := rowHdrW; cx > x && a.Col > 0; {196+ a.Col--197+ cx -= g.sheet.ColWidth(a.Col)198+ }199+ }200+ return clampAddr(a)201+}202+203+func (s *chartSel) status(m *Model) (string, string) {204+ c, ok := s.chart(m)205+ if !ok {206+ return "", ""207+ }208+ desc := m.th.Muted.Render("Chart of " + c.Data.String())209+ pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}210+ for {211+ keys := m.th.KeyHints(pairs...)212+ switch {213+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:214+ return desc, keys215+ case desc != "":216+ desc = ""217+ case len(pairs) > 4:218+ pairs = append(pairs[:len(pairs)-4], pairs[len(pairs)-2:]...) // keep Esc219+ default:220+ return "", keys221+ }222+ }223+}224+225+// contextLine says what's selected and how to change it.226+func (s *chartSel) contextLine(m *Model) (string, string) {227+ c, ok := s.chart(m)228+ if !ok {229+ return "", ""230+ }231+ return m.th.Key.Render(c.Type.Title()+" chart") + m.th.Muted.Render(" of ") + c.Data.String() +232+ m.th.Muted.Render(" drag to move, drag the corner to resize"), ""233+}234+235+const (236+ chartDragNone = iota237+ chartDragMove238+ chartDragResize239+)@@ -4,8 +4,9 @@ import (
44 "strconv"55 66 tea "charm.land/bubbletea/v2"7- "github.com/FelineStateMachine/012/internal/sheet"87 "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/sheet"910 )1011 1112 // Column widths: typed on the context line with a live preview, dragged@@ -4,8 +4,9 @@ import (
44 "strings"55 66 tea "charm.land/bubbletea/v2"7- "github.com/FelineStateMachine/012/internal/ui/theme"87 "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/ui/theme"910 )1011 1112 // choiceBar is a small question asked on the context line and answered@@ -7,9 +7,10 @@ import (
77 "testing"88 99 tea "charm.land/bubbletea/v2"10- "github.com/FelineStateMachine/012/internal/ui/theme"1110 uv "github.com/charmbracelet/ultraviolet"1211 "github.com/charmbracelet/x/ansi"12+13+ "github.com/FelineStateMachine/012/internal/ui/theme"1314 )1415 1516 func TestLinksRenderAsHyperlinks(t *testing.T) {@@ -5,8 +5,9 @@ import (
55 "strings"66 77 tea "charm.land/bubbletea/v2"8- "github.com/FelineStateMachine/012/internal/ui/theme"98 "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/theme"1011 )1112 1213 // The menu bar follows Google Sheets: File Edit View Insert Format Data@@ -8,9 +8,10 @@ import (
88 "strings"99 1010 tea "charm.land/bubbletea/v2"11- "github.com/FelineStateMachine/012/internal/ui/theme"1211 "github.com/charmbracelet/x/ansi"1312 "github.com/sahilm/fuzzy"13+14+ "github.com/FelineStateMachine/012/internal/ui/theme"1415 )1516 1617 // The command palette ("Search the menus" in Sheets, Alt+/) finds any