Skip to content

Commit f9bcdb6

Browse files
feat(preview): floating modal + async render + raw-first toggle (#40)
Three intertwined fixes to the preview experience. ## Floating modal (Space) Was: full-screen takeover that didn't feel like a modal. Now: centered floating box (~80% × 80%) with rounded accent border, overlaid on the still-rendered underlying view. Uses `charmbracelet/x/ansi.Cut` for cell-aware substring operations so the overlay survives ANSI escape codes. `padBlock` normalises the background to a full screenW × screenH grid first — without that, short matrix-view rows produced a "left strip" of width 0 and the modal drifted to column 0 on those rows. ## Async glamour render Was: glamour was invoked inside `View()`, blocking every keystroke on the cold-render path even with a cache. j/k felt laggy in side mode. Now: rendering is dispatched in a `tea.Cmd` (goroutine), result comes back as `PreviewRenderedMsg`, the model stores it. `View()` only ever reads the cache. `ensurePreviewRendered()` is called from key handlers that change the relevant inputs (preview toggle, j/k, resize, scan). This is the canonical Bubble Tea pattern for expensive work and the reason `bubbles/spinner`, `glow`, etc. stay responsive. ## Raw-first + r toggle Was: preview opened directly in glamour-rendered markdown. Now: opens showing the **raw file content** (instant — just split `rec.Raw` on newlines). Pressing `r` toggles to rendered. The glamour goroutine is dispatched the moment the preview opens, so the cache is almost always warm by the time the user presses `r`. Added `Raw string` to `SkillRecord` to store the original file bytes (byte-exact, not reconstructed from frontmatter + body). ## Keymap - Space — toggle modal preview - Shift+P — toggle side-panel preview - r — toggle raw ↔ rendered (preview only) - R — re-scan - j/k — scroll preview (when it owns input) else navigate skills - Tab — swap focus between skill list and preview pane (side mode) - Esc — close any preview Toolbar's `r` label is dynamic: shows `→ rendered` when in raw and `→ raw` when in rendered, so the user always knows the current mode and what r will do next. Avoids fighting lipgloss layout to put a tag in the header. Tests cover the keymap state machine end-to-end (space, P, r, Esc, cross-switching).
1 parent 5eeab6f commit f9bcdb6

9 files changed

Lines changed: 680 additions & 85 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ require (
77
github.com/charmbracelet/bubbletea v1.3.4
88
github.com/charmbracelet/glamour v0.8.0
99
github.com/charmbracelet/lipgloss v1.1.0
10+
github.com/charmbracelet/x/ansi v0.8.0
11+
github.com/muesli/reflow v0.3.0
1012
gopkg.in/yaml.v3 v3.0.1
1113
)
1214

@@ -16,7 +18,6 @@ require (
1618
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1719
github.com/aymerick/douceur v0.2.0 // indirect
1820
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
19-
github.com/charmbracelet/x/ansi v0.8.0 // indirect
2021
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
2122
github.com/charmbracelet/x/term v0.2.1 // indirect
2223
github.com/dlclark/regexp2 v1.11.0 // indirect
@@ -29,7 +30,6 @@ require (
2930
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
3031
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3132
github.com/muesli/cancelreader v0.2.2 // indirect
32-
github.com/muesli/reflow v0.3.0 // indirect
3333
github.com/muesli/termenv v0.16.0 // indirect
3434
github.com/rivo/uniseg v0.4.7 // indirect
3535
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect

internal/app/model.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ type ScanDoneMsg struct {
8484
Err error
8585
}
8686

87+
// PreviewRenderedMsg carries a finished glamour render back to the event
88+
// loop. Dispatched by a tea.Cmd running in a goroutine — never block
89+
// View() or Update() on the actual render.
90+
type PreviewRenderedMsg struct {
91+
Key string
92+
Lines []string
93+
}
94+
8795
// Model is the single source of truth for all app state.
8896
type Model struct {
8997
// Infrastructure
@@ -110,6 +118,14 @@ type Model struct {
110118
Focus FocusPanel
111119
PreviewMode ui.PreviewMode
112120

121+
// Preview state. PreviewScroll is the current vertical scroll offset
122+
// within the preview body. PreviewRendered toggles between raw file
123+
// content (default — fast, no glamour pass) and the glamour-rendered
124+
// markdown (R toggles). Preview caches the expensive rendered output.
125+
PreviewScroll int
126+
PreviewRendered bool
127+
Preview ui.Preview
128+
113129
// Operation overlay
114130
OpMode OpMode
115131
OpSkill *scan.SkillRecord
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package app
2+
3+
import (
4+
"testing"
5+
6+
tea "github.com/charmbracelet/bubbletea"
7+
"github.com/heidihowilson/skillscope/internal/harness"
8+
"github.com/heidihowilson/skillscope/internal/ui"
9+
)
10+
11+
func key(s string) tea.KeyMsg {
12+
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
13+
}
14+
15+
func newKMTestModel() *Model {
16+
m := NewModel(harness.Context{})
17+
m.Width = 100
18+
m.Height = 30
19+
return m
20+
}
21+
22+
func TestPreviewKeymap_Space_TogglesModal(t *testing.T) {
23+
m := newKMTestModel()
24+
if m.PreviewMode != ui.PreviewOff {
25+
t.Fatalf("initial PreviewMode = %v, want Off", m.PreviewMode)
26+
}
27+
m.Update(key(" "))
28+
if m.PreviewMode != ui.PreviewModal {
29+
t.Errorf("after first Space: PreviewMode = %v, want Modal", m.PreviewMode)
30+
}
31+
m.Update(key(" "))
32+
if m.PreviewMode != ui.PreviewOff {
33+
t.Errorf("after second Space: PreviewMode = %v, want Off", m.PreviewMode)
34+
}
35+
}
36+
37+
func TestPreviewKeymap_LowercaseP_DoesNotTriggerPreview(t *testing.T) {
38+
m := newKMTestModel()
39+
m.Update(key("p"))
40+
if m.PreviewMode != ui.PreviewOff {
41+
t.Errorf("lowercase p should no longer open preview, got %v", m.PreviewMode)
42+
}
43+
}
44+
45+
func TestPreviewKeymap_UppercaseP_TogglesSide(t *testing.T) {
46+
m := newKMTestModel()
47+
m.Update(key("P"))
48+
if m.PreviewMode != ui.PreviewSide {
49+
t.Errorf("after first P: PreviewMode = %v, want Side", m.PreviewMode)
50+
}
51+
m.Update(key("P"))
52+
if m.PreviewMode != ui.PreviewOff {
53+
t.Errorf("after second P: PreviewMode = %v, want Off", m.PreviewMode)
54+
}
55+
}
56+
57+
func TestPreviewKeymap_CrossSwitching(t *testing.T) {
58+
m := newKMTestModel()
59+
m.Update(key(" "))
60+
if m.PreviewMode != ui.PreviewModal {
61+
t.Fatalf("setup: PreviewMode = %v, want Modal", m.PreviewMode)
62+
}
63+
m.Update(key("P"))
64+
if m.PreviewMode != ui.PreviewSide {
65+
t.Errorf("Modal + P should switch to Side, got %v", m.PreviewMode)
66+
}
67+
m.Update(key(" "))
68+
if m.PreviewMode != ui.PreviewModal {
69+
t.Errorf("Side + Space should switch to Modal, got %v", m.PreviewMode)
70+
}
71+
}
72+
73+
func TestPreviewKeymap_EscClosesAnyPreview(t *testing.T) {
74+
for _, start := range []ui.PreviewMode{ui.PreviewSide, ui.PreviewModal} {
75+
m := newKMTestModel()
76+
m.PreviewMode = start
77+
m.Update(tea.KeyMsg{Type: tea.KeyEsc})
78+
if m.PreviewMode != ui.PreviewOff {
79+
t.Errorf("from %v, Esc should close to Off, got %v", start, m.PreviewMode)
80+
}
81+
}
82+
}

0 commit comments

Comments
 (0)