// Package app is glint's top-level Bubbletea model. It owns the active mode and // routes messages to the editor, picker, or preview sub-models. package app import ( "fmt" "math" "os" "path/filepath" "strconv" "strings" "time" "glint/internal/config" "glint/internal/editor" "glint/internal/export" "glint/internal/help" "glint/internal/picker" "glint/internal/preview" "glint/internal/spell" "glint/internal/theme" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // Mode selects which sub-view is active. type Mode int const ( ModeEditor Mode = iota ModePicker ModePreview ModeSaveAs ModeFind ModeHelp ModeGotoLine ModeSpell ) // pendingDiscard tracks which open-while-dirty action is awaiting confirmation. type pendingDiscard int const ( discardNone pendingDiscard = iota discardPicker discardDaily discardNew discardInbox ) // Canvas layout: a centered, percentage-width text column with a little top air. const ( canvasRatio = 0.75 canvasMax = 120 canvasMin = 24 canvasTopPad = 3 ) // App is the root model. type App struct { mode Mode cfg config.Config theme theme.Theme editor *editor.Editor preview *preview.Model picker *picker.Model saveInput textinput.Model // one-line "save as" prompt for unnamed buffers findInput textinput.Model // one-line in-document find prompt (TASK-007) gotoInput textinput.Model // one-line go-to-line prompt (TASK-012) helpView viewport.Model // scrollable keybind overlay (TASK-011) cursorMem map[string]editor.Position // per-file cursor memory, this session (TASK-012) spell spellPopup // active misspelled-word popup (TASK-020) path string pickerRoot string // directory the current picker is browsing saveDir string // where an unnamed buffer's save-as lands ("" → inbox) status string width int height int quitArmed bool // true after a dirty Ctrl+Q, awaiting confirm pending pendingDiscard // armed open-while-dirty confirmation mouseDragged bool // a drag motion happened since the last left-press (TASK-027) } // New builds an App with an empty editor. func New(cfg config.Config) *App { th := theme.Resolve(cfg.Theme) ed := editor.New() ed.SetTheme(th) ti := textinput.New() ti.Prompt = "save as › " ti.Placeholder = "name…" fi := textinput.New() fi.Prompt = "find › " fi.Placeholder = "text…" gi := textinput.New() gi.Prompt = "go to line › " gi.Placeholder = "number…" hv := viewport.New(0, 0) hv.SetContent(help.Text) a := &App{ mode: ModeEditor, cfg: cfg, theme: th, editor: ed, saveInput: ti, findInput: fi, gotoInput: gi, helpView: hv, cursorMem: map[string]editor.Position{}, } a.preview = preview.New(a.glamourStyle()) a.preview.SetColors(previewColors(th)) a.initSpell() return a } // initSpell loads the embedded dictionary and personal word list, then sets the // session spellcheck toggle from config (auto/on enable it; off disables). A load // failure leaves spellcheck inert rather than blocking startup. func (a *App) initSpell() { d, err := spell.Load() if err != nil { return } d.SetPersonalPath(config.DictPath()) _ = d.LoadPersonal() a.editor.SetDict(d) a.editor.SetSpell(!strings.EqualFold(a.cfg.Spellcheck, "off")) } // previewColors maps a theme to the glamour preview's color set. func previewColors(th theme.Theme) preview.Colors { return preview.Colors{ Background: string(th.Background), Text: string(th.Text), Heading: string(th.Heading), Code: string(th.Code), Link: string(th.Link), } } // Load reads a file into the editor and switches to edit mode. func (a *App) Load(path string) error { data, err := os.ReadFile(path) if err != nil { return err } a.rememberCursor() // stash the outgoing file's position before switching a.editor.SetContent(data) a.editor.SetLanguage(path) a.path = path if pos, ok := a.cursorMem[path]; ok { a.editor.SetCursor(pos) // restore where we left this file } a.mode = ModeEditor a.status = path return nil } // rememberCursor records the current file's cursor so reopening it this session // returns to the same spot (TASK-012). func (a *App) rememberCursor() { if a.path != "" { a.cursorMem[a.path] = a.editor.Cursor } } // Start picks the initial view: an explicit path, today's daily note, or the // picker when neither is given. func (a *App) Start(path string, daily bool) error { switch { case path != "": return a.Load(path) case daily: _, cmd := a.openDaily() _ = cmd return nil default: _, cmd := a.openPicker() _ = cmd return nil } } // StartPreview opens path straight into the Glamour read view (glint -p), making // glint a glow-style reader. Ctrl+P toggles back to the editor, which already // holds the loaded file. With no path it falls back to the picker (TASK-019). func (a *App) StartPreview(path string) error { if path == "" { return a.Start("", false) } if err := a.Load(path); err != nil { return err } a.togglePreview() // render the buffer and switch to ModePreview return nil } func (a *App) Init() tea.Cmd { return nil } // Update routes messages. Global keys are handled first, then mode-specific. func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: a.setSize(msg.Width, msg.Height) return a, nil case tea.KeyMsg: return a.handleKey(msg) case tea.MouseMsg: return a.handleMouse(msg) } return a, nil } // handleMouse moves the cursor on a left click and scrolls on wheel events. func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { const step = 3 if msg.Button == tea.MouseButtonLeft { if a.mode != ModeEditor { return a, nil } // The first editor visual row sits at screen row topPad; a click at // screen row Y is that many rows into the viewport. vi := a.editor.Scroll + msg.Y - a.topPad() + 1 col := msg.X - a.leftMargin() switch msg.Action { case tea.MouseActionPress: // Anchor the cursor (dropping any prior selection) for a click or // the start of a drag (TASK-027). a.mouseDragged = false a.editor.MouseAnchor(vi, col) // A press on a checkbox glyph toggles it, undoably (TASK-023). if a.editor.OnCheckboxBracket() { a.editor.PushUndo() a.editor.ToggleCheckbox() } case tea.MouseActionMotion: // Dragging extends the selection from the press anchor (TASK-027). a.mouseDragged = true a.editor.MouseExtendTo(vi, col) case tea.MouseActionRelease: // A plain click (no drag) on a flagged word opens its suggestion // popup; a drag selects instead, so only pop on a clean click. if !a.mouseDragged { a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col) } } return a, nil } var delta int switch msg.Button { case tea.MouseButtonWheelUp: delta = -step case tea.MouseButtonWheelDown: delta = step default: return a, nil } switch a.mode { case ModePreview: return a, a.preview.Update(msg) // the viewport handles wheel scrolling case ModePicker: key := tea.KeyMsg{Type: tea.KeyDown} if delta < 0 { key.Type = tea.KeyUp } for i := 0; i < step; i++ { a.picker.Update(key) } default: // editor / save-as a.editor.ScrollBy(delta) } return a, nil } func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Drop leaked SGR mouse residue: a burst of wheel events can be split by // the input parser and surface as a KeyRunes of `<64;68;26M…` text that // would otherwise be typed into the buffer (TASK-029). if msg.Type == tea.KeyRunes && looksLikeMouseLeak(string(msg.Runes)) { return a, nil } // Any key other than a second Ctrl+Q disarms the quit confirmation. if msg.Type != tea.KeyCtrlQ { a.quitArmed = false } // Disarm pending-discard unless the same action is being re-pressed. repressed := (msg.Type == tea.KeyCtrlF && a.pending == discardPicker) || (msg.Type == tea.KeyCtrlD && a.pending == discardDaily) || (msg.Type == tea.KeyCtrlN && a.pending == discardNew) || (msg.Type == tea.KeyCtrlB && a.pending == discardInbox) if !repressed { a.pending = discardNone } // Alt+; opens the spellcheck popup on a flagged word at the cursor. if msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == ";" { if a.openSpellPopup() { return a, nil } a.status = "No misspelling at the cursor" return a, nil } switch msg.Type { case tea.KeyCtrlQ: if a.editor.Dirty && !a.quitArmed { a.quitArmed = true a.status = "Unsaved changes — Ctrl+Q again to quit" return a, nil } return a, tea.Quit case tea.KeyCtrlS: return a.save() case tea.KeyCtrlE: return a.exportPDF() case tea.KeyCtrlP: return a.togglePreview() case tea.KeyCtrlT: return a.cycleTheme() case tea.KeyCtrlF: if a.editor.Dirty && a.pending != discardPicker { a.pending = discardPicker a.status = "Unsaved changes — Ctrl+F again to discard" return a, nil } a.pending = discardNone return a.openPicker() case tea.KeyCtrlD: if a.editor.Dirty && a.pending != discardDaily { a.pending = discardDaily a.status = "Unsaved changes — Ctrl+D again to discard" return a, nil } a.pending = discardNone return a.openDaily() case tea.KeyCtrlN: return a.newFile(a.currentDir(), discardNew) case tea.KeyCtrlB: return a.newFile(a.cfg.InboxRoot(), discardInbox) case tea.KeyCtrlZ: if a.mode == ModeEditor { a.editor.Undo() } return a, nil case tea.KeyCtrlY: if a.mode == ModeEditor { a.editor.Redo() } return a, nil case tea.KeyCtrlC: return a.copySelection(false) case tea.KeyCtrlX: return a.copySelection(true) case tea.KeyCtrlV: return a.paste() case tea.KeyCtrlG: return a.openFind() case tea.KeyCtrlL: return a.openGoto() case tea.KeyCtrlUnderscore: // Ctrl+/ toggles the help overlay return a.toggleHelp() case tea.KeyEsc: if a.mode == ModeFind { a.editor.ClearFind() } else if a.editor.HasSelection() { a.editor.ClearSelection() } a.mode = ModeEditor return a, nil } switch a.mode { case ModeEditor: a.editor.HandleKey(msg) case ModeSaveAs: if msg.Type == tea.KeyEnter { return a.saveAs() } var cmd tea.Cmd a.saveInput, cmd = a.saveInput.Update(msg) return a, cmd case ModeFind: return a.handleFindKey(msg) case ModeGotoLine: return a.handleGotoKey(msg) case ModeSpell: return a.handleSpellKey(msg) case ModeHelp: var cmd tea.Cmd a.helpView, cmd = a.helpView.Update(msg) // arrows / PgUp / PgDn scroll return a, cmd case ModePreview: return a, a.preview.Update(msg) case ModePicker: if msg.Type == tea.KeyEnter { if sel := a.picker.Selected(); sel != "" { if err := a.Load(sel); err != nil { a.status = "Open failed: " + err.Error() } else if ln := a.picker.SelectedLine(); ln > 0 { a.editor.GotoLine(ln) // a content-search hit opens at the match (TASK-013) } } return a, nil } return a, a.picker.Update(msg) } return a, nil } func (a *App) save() (tea.Model, tea.Cmd) { if a.mode == ModeSaveAs { return a.saveAs() // Ctrl+S confirms an open save-as prompt } if a.path == "" { return a.promptSaveAs() // unnamed buffer → ask for a name } if err := os.WriteFile(a.path, a.editor.Bytes(), 0o644); err != nil { a.status = "Save failed: " + err.Error() return a, nil } a.editor.Dirty = false a.status = "Saved " + a.path return a, nil } // exportPDF renders the buffer to a self-contained, printable HTML document in // the house style and opens it in the browser, where the user picks // Print → Save as PDF (TASK-021). No external converter is required. func (a *App) exportPDF() (tea.Model, tea.Cmd) { md := string(a.editor.Bytes()) out, err := export.Write(a.path, md, a.exportOptions(a.path, md)) if err != nil { a.status = "Export failed: " + err.Error() return a, nil } if err := export.OpenInBrowser(out); err != nil { // The file is written; only the auto-open failed. Tell the user where. a.status = "Exported " + out + " (open it, then Print → Save as PDF)" return a, nil } a.status = "Exported " + out + " — Print → Save as PDF in the browser" return a, nil } // exportOptions builds the house-style export options for path/markdown, shared // by the in-editor Ctrl+E and the headless `glint -e` command (TASK-030). func (a *App) exportOptions(path, md string) export.Options { return export.Options{ Title: export.Title(path, md), Theme: export.MapTheme(a.theme.Name), FontDisplay: a.cfg.FontDisplay, FontBody: a.cfg.FontBody, FontMono: a.cfg.FontMono, Cover: true, } } // writeExport renders the markdown file at path to a printable HTML document // beside it and returns the output path, without opening a browser (TASK-030). func (a *App) writeExport(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", err } md := string(data) return export.Write(path, md, a.exportOptions(path, md)) } // ExportFile is the headless form of Ctrl+E for `glint -e `: it renders // path to house-style HTML, opens it in the browser (best-effort), and returns // the output path (TASK-030). func (a *App) ExportFile(path string) (string, error) { out, err := a.writeExport(path) if err != nil { return "", err } _ = export.OpenInBrowser(out) // best-effort; the path is returned regardless return out, nil } // promptSaveAs opens the one-line save-as prompt for an unnamed buffer. func (a *App) promptSaveAs() (tea.Model, tea.Cmd) { a.saveInput.SetValue("") a.saveInput.Focus() a.mode = ModeSaveAs a.status = "Save as — type a name, Enter to save, Esc to cancel" return a, nil } // saveAs writes the unnamed buffer to a name typed at the prompt, resolved under // the inbox directory, then binds the buffer to that path. func (a *App) saveAs() (tea.Model, tea.Cmd) { root := a.saveDir if root == "" { root = a.cfg.InboxRoot() } p := picker.NewNotePath(root, a.saveInput.Value()) if p == "" { a.status = "Type a name first" return a, nil } if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { a.status = "Save dir failed: " + err.Error() return a, nil } if err := os.WriteFile(p, a.editor.Bytes(), 0o644); err != nil { a.status = "Save failed: " + err.Error() return a, nil } a.path = p a.editor.Dirty = false a.mode = ModeEditor a.status = "Saved " + p return a, nil } // openFind opens the in-document find bar over the current editor buffer. // Ctrl+F is already the file picker, so find is Ctrl+G. func (a *App) openFind() (tea.Model, tea.Cmd) { if a.mode != ModeEditor { return a, nil } a.findInput.SetValue("") a.findInput.Focus() a.editor.ClearFind() a.mode = ModeFind a.status = "Find — type to search, Enter/↓ next, Shift+Tab/↑ prev, Esc to close" return a, nil } // openGoto opens the go-to-line prompt over the current editor buffer (Ctrl+L). func (a *App) openGoto() (tea.Model, tea.Cmd) { if a.mode != ModeEditor { return a, nil } a.gotoInput.SetValue("") a.gotoInput.Focus() a.mode = ModeGotoLine a.status = "Go to line — type a number, Enter to jump, Esc to cancel" return a, nil } // handleGotoKey drives the go-to-line prompt: Enter jumps to the typed line, // other keys edit the (digits-only) query live. func (a *App) handleGotoKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyEnter { if n, err := strconv.Atoi(strings.TrimSpace(a.gotoInput.Value())); err == nil { a.editor.GotoLine(n) } a.mode = ModeEditor a.status = "" return a, nil } // Only accept digits so the prompt stays a line number. if msg.Type == tea.KeyRunes { for _, r := range msg.Runes { if r < '0' || r > '9' { return a, nil } } } var cmd tea.Cmd a.gotoInput, cmd = a.gotoInput.Update(msg) return a, cmd } // handleFindKey drives the find bar: Enter/Down cycle to the next match, // Shift+Tab/Up to the previous, and any other key edits the query live. func (a *App) handleFindKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEnter, tea.KeyDown: a.editor.FindNext() a.status = a.findStatus() return a, nil case tea.KeyShiftTab, tea.KeyUp: a.editor.FindPrev() a.status = a.findStatus() return a, nil } var cmd tea.Cmd a.findInput, cmd = a.findInput.Update(msg) a.editor.SetFindQuery(a.findInput.Value()) a.status = a.findStatus() return a, cmd } // findStatus summarizes the current match count for the status bar. func (a *App) findStatus() string { if a.findInput.Value() == "" { return "Find — type to search" } if a.editor.FindCount() == 0 { return "No matches" } return fmt.Sprintf("%d matches", a.editor.FindCount()) } // newFile is the Ctrl+N / Ctrl+I handler: start a new note in dir. From the // picker with a typed query it creates dir/.md; otherwise it opens a // blank buffer whose save-as targets dir (confirming discard if the editor is // dirty, keyed by pend so re-pressing the same key confirms). func (a *App) newFile(dir string, pend pendingDiscard) (tea.Model, tea.Cmd) { if a.mode == ModePicker { if q := strings.TrimSpace(a.picker.Query()); q != "" { return a.openNoteAt(picker.NewNotePath(dir, q)) } a.startBlankIn(dir) return a, nil } if a.editor.Dirty && a.pending != pend { a.pending = pend a.status = "Unsaved changes — press again to discard" return a, nil } a.pending = discardNone a.startBlankIn(dir) return a, nil } // startBlankIn opens an empty, unnamed buffer whose save-as targets dir. func (a *App) startBlankIn(dir string) { a.saveDir = dir a.editor.SetContent(nil) a.path = "" a.mode = ModeEditor a.status = "New note" } // openNoteAt creates the note at p (if absent) and opens it. func (a *App) openNoteAt(p string) (tea.Model, tea.Cmd) { if p == "" { a.status = "Type a name first" return a, nil } if _, err := os.Stat(p); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { a.status = "New note dir failed: " + err.Error() return a, nil } if err := os.WriteFile(p, []byte{}, 0o644); err != nil { a.status = "New note failed: " + err.Error() return a, nil } } if err := a.Load(p); err != nil { a.status = "Open failed: " + err.Error() } return a, nil } // copySelection copies the editor selection to the clipboard, deleting it when // cut is true. No-op outside the editor or with no selection. func (a *App) copySelection(cut bool) (tea.Model, tea.Cmd) { if a.mode != ModeEditor { return a, nil } text := a.editor.SelectedText() if text == "" { return a, nil } if err := clipboard.WriteAll(text); err != nil { a.status = "Copy failed: " + err.Error() return a, nil } if cut { a.editor.PushUndo() a.editor.DeleteSelection() a.status = "Cut" } else { a.status = "Copied" } return a, nil } // paste inserts the clipboard contents at the cursor (replacing any selection). func (a *App) paste() (tea.Model, tea.Cmd) { if a.mode != ModeEditor { return a, nil } text, err := clipboard.ReadAll() if err != nil || text == "" { return a, nil } a.pasteText(text) return a, nil } // pasteText inserts text at the cursor. Pasting a bare URL over a selection wraps // it as a markdown link [selection](url); otherwise it replaces the selection (or // inserts at the cursor) verbatim (TASK-012). func (a *App) pasteText(text string) { a.editor.PushUndo() if a.editor.HasSelection() && isURL(text) { sel := a.editor.SelectedText() a.editor.DeleteSelection() a.editor.InsertText("[" + sel + "](" + text + ")") return } a.editor.InsertText(text) } // isURL reports whether text is a single http(s) URL (no embedded whitespace). func isURL(text string) bool { if !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") { return false } return !strings.ContainsAny(text, " \t\n") } // currentDir is the "same directory" for Ctrl+N: the picker root in the picker, // the open file's folder in the editor, else the working directory. func (a *App) currentDir() string { if a.mode == ModePicker { return a.pickerRoot } if a.path != "" { return filepath.Dir(a.path) } return a.cfg.WorkingDir() } // StartNewIn is the `glint -n` entry point: a blank buffer targeting dir when // name is empty, or a new note created at dir/.md. func (a *App) StartNewIn(dir, name string) error { if strings.TrimSpace(name) == "" { a.startBlankIn(dir) return nil } p := picker.NewNotePath(dir, name) if _, err := os.Stat(p); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return err } if err := os.WriteFile(p, []byte{}, 0o644); err != nil { return err } } return a.Load(p) } // StartNew creates a new note in the inbox (kept for callers/tests). func (a *App) StartNew(name string) error { return a.StartNewIn(a.cfg.InboxRoot(), name) } // glamourStyle is the explicit config override if set, else the theme's style. func (a *App) glamourStyle() string { if a.cfg.GlamourStyle != "" { return a.cfg.GlamourStyle } return a.theme.GlamourStyle } // cycleTheme advances to the next theme and repaints the editor, preview, and // (if open) the picker. func (a *App) cycleTheme() (tea.Model, tea.Cmd) { a.theme = theme.Next(a.theme.Name) a.editor.SetTheme(a.theme) a.preview.SetStyle(a.glamourStyle()) a.preview.SetColors(previewColors(a.theme)) // Re-render an open preview so its glamour style follows the new theme // (otherwise it keeps the old light/dark block until the next toggle). if a.mode == ModePreview { _ = a.preview.Render(string(a.editor.Bytes())) } if a.picker != nil { a.picker.SetTheme(a.theme) a.picker.SetStyle(a.glamourStyle()) } // The theme name already shows on the right of the status bar; don't clobber // the filename on the left with a transient "Theme: …" message. return a, nil } // toggleHelp opens or closes the keybind overlay, sourced from help.Text (the // same reference printed by `glint -h`). It scrolls back to the top each time it // opens so the overlay always starts at the header. func (a *App) toggleHelp() (tea.Model, tea.Cmd) { if a.mode == ModeHelp { a.mode = ModeEditor return a, nil } a.helpView.GotoTop() a.mode = ModeHelp return a, nil } // togglePreview switches between the editor and the Glamour read view. func (a *App) togglePreview() (tea.Model, tea.Cmd) { if a.mode == ModePreview { a.mode = ModeEditor return a, nil } if err := a.preview.Render(string(a.editor.Bytes())); err != nil { a.status = "Preview failed: " + err.Error() return a, nil } a.mode = ModePreview return a, nil } // contentWidth is the centered text column width: ~65% of the terminal, capped // for readability and floored so it never collapses, never wider than the term. func (a *App) contentWidth() int { w := int(math.Round(float64(a.width) * canvasRatio)) if w > canvasMax { w = canvasMax } if w < canvasMin { w = canvasMin } if w > a.width { w = a.width } return w } // leftMargin centers the content column in the terminal. func (a *App) leftMargin() int { m := (a.width - a.contentWidth()) / 2 if m < 0 { m = 0 } return m } func (a *App) topPad() int { return canvasTopPad } func (a *App) setSize(w, h int) { a.width = w a.height = h cw := a.contentWidth() textRows := h - 1 - a.topPad() // status bar + top pad if textRows < 1 { textRows = 1 } a.editor.SetSize(cw, textRows) a.preview.SetSize(cw, textRows) // The help overlay is a bordered box on the canvas: fit it inside the column // (minus the 1-cell border each side) and the text rows (minus border + a // title/footer line each). a.helpView.Width = maxInt(cw-2, 1) a.helpView.Height = maxInt(textRows-4, 1) if a.picker != nil { a.picker.SetSize(w, h-1) // picker keeps its full-width split } if a.mode == ModePreview { _ = a.preview.Render(string(a.editor.Bytes())) // re-wrap at the new width } } // openPicker builds a fresh picker over the working directory and switches to it. func (a *App) openPicker() (tea.Model, tea.Cmd) { return a.openPickerAt(a.cfg.WorkingDir()) } // openPickerAt opens the picker rooted at dir and records the root. func (a *App) openPickerAt(dir string) (tea.Model, tea.Cmd) { p, err := picker.New(dir, a.theme, a.cfg.DailyPath(time.Now()), a.glamourStyle()) if err != nil { a.status = "Picker failed: " + err.Error() return a, nil } p.SetSize(a.width, a.height-1) a.picker = p a.pickerRoot = dir a.mode = ModePicker return a, nil } // StartPickerIn opens the picker over dir (used by -v/-i/-d and the default). func (a *App) StartPickerIn(dir string) error { _, _ = a.openPickerAt(dir) return nil } // StartVault opens the picker over the configured vault. func (a *App) StartVault() error { return a.StartPickerIn(a.cfg.Vault()) } // openDaily opens today's daily note, creating the file and directory if needed. func (a *App) openDaily() (tea.Model, tea.Cmd) { path := a.cfg.DailyPath(time.Now()) if _, err := os.Stat(path); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { a.status = "Daily dir failed: " + err.Error() return a, nil } if err := os.WriteFile(path, []byte{}, 0o644); err != nil { a.status = "Daily create failed: " + err.Error() return a, nil } } if err := a.Load(path); err != nil { a.status = "Daily open failed: " + err.Error() } return a, nil } // View renders the active sub-view. The editor and preview sit in a centered, // padded column; the picker keeps its own full-width layout. func (a *App) View() string { if a.mode == ModePicker { body := a.picker.View() if !strings.HasSuffix(body, "\n") { body += "\n" } return body + a.statusBar() } var body string switch a.mode { case ModeHelp: body = a.helpOverlay() case ModePreview: body = a.preview.View() default: body = a.editor.View() // editor stays visible beneath the save-as prompt } bottom := a.statusBar() switch a.mode { case ModeSaveAs: bottom = a.saveBar() case ModeFind: bottom = a.findBar() case ModeGotoLine: bottom = a.gotoBar() case ModeSpell: bottom = a.spellBar() } return a.paintCanvas(body) + bottom } // helpOverlay renders the keybind reference (help.Text) as a centered, themed // bordered box on the canvas, with a title and a close/scroll footer. func (a *App) helpOverlay() string { title := lipgloss.NewStyle().Foreground(a.theme.Heading).Bold(true). Render("glint — keys & commands") footer := lipgloss.NewStyle().Foreground(a.theme.Muted). Render("Ctrl+/ or Esc to close · ↑/↓ to scroll") inner := title + "\n\n" + a.helpView.View() + "\n\n" + footer box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(a.theme.Heading). BorderBackground(a.theme.Background). Background(a.theme.Background). Foreground(a.theme.Text). Width(maxInt(a.contentWidth()-2, 1)) return box.Render(inner) } // findBar renders the find prompt as a themed full-width bottom bar, with the // match count trailing. func (a *App) findBar() string { bar := lipgloss.NewStyle(). Foreground(a.theme.StatusFg). Background(a.theme.StatusBg). Width(maxInt(a.width, 1)) return bar.Render(" " + a.findInput.View() + " " + a.findStatus() + " ") } // gotoBar renders the go-to-line prompt as a themed full-width bottom bar. func (a *App) gotoBar() string { bar := lipgloss.NewStyle(). Foreground(a.theme.StatusFg). Background(a.theme.StatusBg). Width(maxInt(a.width, 1)) return bar.Render(" " + a.gotoInput.View() + " ") } // saveBar renders the save-as prompt as a themed full-width bottom bar. func (a *App) saveBar() string { bar := lipgloss.NewStyle(). Foreground(a.theme.StatusFg). Background(a.theme.StatusBg). Width(maxInt(a.width, 1)) return bar.Render(" " + a.saveInput.View() + " ") } // paintCanvas centers body in the theme's paper: a top pad, then each body line // indented by the left margin, every line filled to the full terminal width with // the theme background so margins and empty space carry the theme color rather // than the terminal default. The result is exactly height-1 rows (the status bar // is the final row) and always ends with a trailing newline before it. func (a *App) paintCanvas(body string) string { bg := lipgloss.NewStyle().Background(a.theme.Background).Width(maxInt(a.width, 1)) lm := strings.Repeat(" ", a.leftMargin()) rows := make([]string, 0, a.height) for p := 0; p < a.topPad(); p++ { rows = append(rows, bg.Render("")) } for _, ln := range strings.Split(body, "\n") { if ln == "" { rows = append(rows, bg.Render("")) } else { rows = append(rows, bg.Render(lm+ln)) } } return strings.Join(rows, "\n") + "\n" } func (a *App) statusBar() string { bar := lipgloss.NewStyle(). Foreground(a.theme.StatusFg). Background(a.theme.StatusBg). Width(maxInt(a.width, 1)) dirty := "" if a.editor.Dirty { dirty = " ●" } // Left: a transient message takes over; otherwise the repo (or parent // folder) and filename. var left string if a.status != "" { left = a.status + dirty } else if a.path != "" { left = repoOrParent(a.path) + "/" + filepath.Base(a.path) + dirty } else { left = filepath.Base(a.currentDir()) + "/" + dirty } // Right: [sel stats ·] Ln r:c · N words · theme · ? (high→low priority). pos := a.editor.Cursor segs := []string{fmt.Sprintf("Ln %d:%d", pos.Row+1, pos.Col+1)} if c, w, ok := a.editor.SelectionStats(); ok { segs = append(segs, fmt.Sprintf("%d chars · %d words selected", c, w)) } segs = append(segs, fmt.Sprintf("%d words", a.editor.WordCount()), a.theme.Name, "? ctrl+/", ) return bar.Render(layoutStatus(" "+left, strings.Join(segs, " · ")+" ", maxInt(a.width, 1))) } // layoutStatus left-justifies left and right-justifies right within width, // dropping trailing right-hand segments (split on " · ") then truncating to fit // at narrow widths so the bar never overflows. func layoutStatus(left, right string, width int) string { for lipgloss.Width(left)+lipgloss.Width(right)+1 > width && strings.Contains(right, " · ") { i := strings.LastIndex(right, " · ") right = right[:i] + right[len(right)-1:] // keep the trailing space } if lipgloss.Width(left)+lipgloss.Width(right)+1 > width { right = "" // still too wide: drop the right group entirely } gap := width - lipgloss.Width(left) - lipgloss.Width(right) if gap < 1 { // Left alone overflows: truncate it to the available width. return truncate(left, width) } return left + strings.Repeat(" ", gap) + right } // truncate cuts s to a maximum display width, appending … when shortened. func truncate(s string, width int) string { if lipgloss.Width(s) <= width { return s } if width <= 1 { return strings.Repeat(" ", maxInt(width, 0)) } r := []rune(s) for len(r) > 0 && lipgloss.Width(string(r))+1 > width { r = r[:len(r)-1] } return string(r) + "…" } // repoOrParent returns the name of the git repository containing path (the // folder holding .git), or the file's immediate parent folder when path is not // inside a repo. func repoOrParent(path string) string { parent := filepath.Dir(path) for dir := parent; ; { if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { return filepath.Base(dir) } up := filepath.Dir(dir) if up == dir { // reached the filesystem root break } dir = up } return filepath.Base(parent) } func maxInt(a, b int) int { if a > b { return a } return b }