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 / notes.go
notes.go 4.7K
1package ui23import (4 "strconv"5 "strings"67 tea "charm.land/bubbletea/v2"8 "charm.land/lipgloss/v2"9 "github.com/charmbracelet/x/ansi"1011 "src.rest/cat/012/internal/sheet"12 "src.rest/cat/012/internal/ui/overlay"13 "src.rest/cat/012/internal/ui/theme"14)1516// Notes follow Sheets' Insert > Note (Shift+F2): a cell with a note shows17// a mark in its top-right corner, the note is on the context line while18// the cell is active, and in a small box beside the cell while the mouse19// is over it. The note is typed on the context line, where Shift+Enter20// or Alt+Enter starts a new line, shown as ↵.2122// noteBreak stands for a line break in a note being typed on the context23// line, which holds one line.24const noteBreak = "↵"2526func init() {27 register(28 &command{id: "note.edit", title: "Note", desc: "Add or edit the active cell's note, shown when the cell is active or hovered",29 edits: cellTarget, run: (*Model).openNote},30 &command{id: "note.delete", title: "Delete notes", desc: "Delete the notes of the selected cells",31 edits: (*Model).selection, run: (*Model).deleteNotes},32 )33 keymap["shift+f2"] = "note.edit"34}3536// openNote asks for the active cell's note on the context line, starting37// from the note it has.38func (m *Model) openNote() tea.Cmd {39 a := m.cur40 m.clearSelection()41 initial := strings.ReplaceAll(m.sheet.Note(a), "\n", noteBreak)42 m.openText("Note on "+a.String()+":", initial, func(m *Model, text string) tea.Cmd {43 note := strings.ReplaceAll(text, noteBreak, "\n")44 had := m.sheet.Note(a) != ""45 if err := m.sheet.SetNote(a, note); err != nil {46 m.fail(err.Error())47 return nil48 }49 m.changed = true50 if strings.TrimSpace(note) == "" && had {51 m.note = "Deleted the note on " + a.String() + "; Ctrl+Z brings it back"52 }53 return nil54 })55 m.prompt.indicator = "NOTE"56 m.prompt.breaks = true57 m.prompt.fresh = false // typing adds to the note rather than replacing it58 return nil59}6061// deleteNotes deletes the notes in the selection.62func (m *Model) deleteNotes() tea.Cmd {63 r := m.selection()64 switch n := m.sheet.ClearNotes(r); n {65 case 0:66 m.note = "No notes in " + r.String()67 case 1:68 m.changed = true69 m.note = "Deleted 1 note"70 default:71 m.changed = true72 m.note = "Deleted " + strconv.Itoa(n) + " notes"73 }74 return nil75}7677// noteLine shows the active cell's note on the context line, its lines78// run together.79func (m *Model) noteLine() string {80 note := m.sheet.Note(m.cur)81 if note == "" {82 return ""83 }84 return m.th.Key.Render("Note") + " " + strings.ReplaceAll(note, "\n", " "+m.th.Muted.Render(noteBreak)+" ")85}8687// noteMark draws a cell's note mark over the last column of its text,88// like Sheets' corner triangle: in the cell's own role where it has one89// (the pointer, the selection), so it reads on any of them.90func (m *Model) noteMark(text string, a sheet.Addr, base lipgloss.Style, colored bool) string {91 w := m.sheet.ColWidth(a.Col)92 if w < 2 {93 return text94 }95 mark := m.th.NoteMark96 if colored {97 mark = base98 }99 return ansi.Truncate(text, w-1, "") + mark.Render("▝")100}101102// noteMaxRows is how many lines of a note the hover box shows.103const noteMaxRows = 8104105// noteBox is the note of the cell under the mouse, in a box beside the106// cell, while nothing else is going on.107func (m *Model) noteBox() (overlay.Box, bool) {108 h := m.mouse.hover109 if h.kind != hitCell && h.kind != hitCheckbox && h.kind != hitDropdown || m.mode != modeReady || m.mouse.drag != dragNone {110 return overlay.Box{}, false111 }112 note := m.sheet.Note(h.addr)113 if note == "" {114 return overlay.Box{}, false115 }116 inner := min(32, m.width-2)117 if inner < 8 {118 return overlay.Box{}, false119 }120 wrapped := strings.Split(ansi.Wrap(note, inner-2, ""), "\n")121 if len(wrapped) > noteMaxRows {122 wrapped = wrapped[:noteMaxRows]123 wrapped[noteMaxRows-1] = ansi.Truncate(wrapped[noteMaxRows-1], inner-3, "") + "…"124 }125 rows := make([]string, len(wrapped))126 for i, line := range wrapped {127 rows[i] = m.th.MenuBar.Render(theme.PadRight(" "+strings.TrimRight(line, " "), inner))128 }129 return m.besideCell(noteBoxID, h.addr, m.th.Frame(inner, "Note", "", rows)), true130}131132// besideCell places a framed box beside the cell at a, its top on the133// cell's row: right of the cell, or left of it when there's no room,134// moved up as far as it must to stay above the status line, and never135// over the column headers.136func (m *Model) besideCell(id string, a sheet.Addr, lines []string) overlay.Box {137 w := ansi.StringWidth(lines[0])138 x, y := m.cellPos(a)139 x += m.sheet.ColWidth(a.Col)140 if x+w > m.width { // no room on the right: the left side141 x = max(x-m.sheet.ColWidth(a.Col)-w, 0)142 }143 x, y = m.clampBox(x, y, w, len(lines)+1) // the status line's row stays clear144 return overlay.Box{ID: id, X: x, Y: max(y, gridTop), Lines: lines}145}146147const noteBoxID = "note"