// 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" "strings" "time" "glint/internal/config" "glint/internal/editor" "glint/internal/picker" "glint/internal/preview" "glint/internal/theme" "github.com/charmbracelet/bubbles/textinput" 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 ) // pendingDiscard tracks which open-while-dirty action is awaiting confirmation. type pendingDiscard int const ( discardNone pendingDiscard = iota discardPicker discardDaily discardNew ) // Canvas layout: a centered, percentage-width text column with a little top air. const ( canvasRatio = 0.75 canvasMax = 120 canvasMin = 24 canvasTopPad = 1 ) // 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 path string status string width int height int quitArmed bool // true after a dirty Ctrl+Q, awaiting confirm pending pendingDiscard // armed open-while-dirty confirmation } // 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…" a := &App{ mode: ModeEditor, cfg: cfg, theme: th, editor: ed, saveInput: ti, } a.preview = preview.New(a.glamourStyle()) return a } // 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.editor.SetContent(data) a.path = path a.mode = ModeEditor a.status = path return nil } // 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 } } 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 scrolls the active view on wheel events without moving the cursor. func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { const step = 3 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) { // 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) if !repressed { a.pending = discardNone } 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.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.KeyEsc: a.mode = ModeEditor return a, nil } switch a.mode { case ModeEditor: if msg.Type == tea.KeyCtrlN { return a.newBlank() } 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 ModePreview: return a, a.preview.Update(msg) case ModePicker: switch msg.Type { case tea.KeyEnter: if sel := a.picker.Selected(); sel != "" { if err := a.Load(sel); err != nil { a.status = "Open failed: " + err.Error() } } return a, nil case tea.KeyCtrlN: return a.newNote() } 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 } // 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) { p := picker.NewNotePath(a.cfg.InboxRoot(), 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 } // newBlank starts a fresh unnamed buffer, confirming discard if the current // buffer is dirty (a second Ctrl+N discards). func (a *App) newBlank() (tea.Model, tea.Cmd) { if a.editor.Dirty && a.pending != discardNew { a.pending = discardNew a.status = "Unsaved changes — Ctrl+N again to discard" return a, nil } a.pending = discardNone a.startBlank() return a, nil } // startBlank resets to an empty, unnamed editor buffer. func (a *App) startBlank() { a.editor.SetContent(nil) a.path = "" a.mode = ModeEditor a.status = "New note" } // StartNew is the entry point for `glint new [name]`: a blank buffer when name // is empty, or a new note created under the inbox when a name is given. func (a *App) StartNew(name string) error { if strings.TrimSpace(name) == "" { a.startBlank() return nil } p := picker.NewNotePath(a.cfg.InboxRoot(), 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) } // 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()) // 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()) } a.status = "Theme: " + a.theme.Name 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) if a.picker != nil { a.picker.SetSize(w, h-1) // picker keeps its full-width split } } // 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()) } // openVault opens the picker over the configured vault, from anywhere. func (a *App) openVault() (tea.Model, tea.Cmd) { return a.openPickerAt(a.cfg.Vault()) } // openPickerAt opens the picker rooted at dir. 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.mode = ModePicker return a, nil } // StartVault is the entry point for `glint vault`: open the picker over the // configured vault directory. func (a *App) StartVault() error { _, _ = a.openVault() return nil } // 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 } // newNote creates a note named after the picker query and opens it. func (a *App) newNote() (tea.Model, tea.Cmd) { p := picker.NewNotePath(a.cfg.InboxRoot(), a.picker.Query()) 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 } // 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 if a.mode == ModePreview { body = a.preview.View() } else { body = a.editor.View() // editor stays visible beneath the save-as prompt } bottom := a.statusBar() if a.mode == ModeSaveAs { bottom = a.saveBar() } return a.paintCanvas(body) + bottom } // 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.status if left == "" { left = "glint" } return bar.Render(fmt.Sprintf(" %s%s ", left, dirty)) } func maxInt(a, b int) int { if a > b { return a } return b }