From 4f1c5bd010b7b86a22eafc697c5e7fad24dd0f7d Mon Sep 17 00:00:00 2001 From: Allie Bayless Date: Sat, 30 May 2026 07:31:32 -0500 Subject: [PATCH] Add layout modes, Block/RenderBlock, PaneScroller, and scroll support New layout modes: SidebarOnly (full-height sidebar + one main pane), Tabbed (tab bar + active pane content), Floating (pane 0 background with panes 1/2 as overlaid panels). Layout mode cycle updated in demo. Block/RenderBlock: multi-line content primitive parallel to Row/RenderRow. Header line uses Item styles (selected/muted states); body indented to prefix width using DetailBody. A Block with no Body is byte-identical to the equivalent RenderRow output. PaneScroller: scroll-state helper (ScrollDown, ScrollUp, ScrollToTop, ClampTo, CanScrollDown). Pane.ScrollOffset applied post-render in renderPane and renderTabbed via shared applyScrollOffset helper. Bug fix: renderTabbed previously ignored ScrollOffset entirely. paneBorders struct replaces rightBorder/bottomBorder bool args on renderPane, enabling full four-sided borders for Floating panels. placeBoxAt extracted from overlayOnBase as shared compositor. Theme and Styles field comments added throughout for library consumers. README updated with layout table, Block/scroll sections, and demo key table. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 148 ++++++++++++++++++++++++++++++++------ cmd/demo/main.go | 87 +++++++++++++++++----- layout.go | 182 +++++++++++++++++++++++++++++++++++++++++------ layout_test.go | 146 ++++++++++++++++++++++++++++++++++++- scroller.go | 31 ++++++++ scroller_test.go | 60 ++++++++++++++++ styles.go | 68 +++++++++++------- theme.go | 26 +++---- 8 files changed, 645 insertions(+), 103 deletions(-) create mode 100644 scroller.go create mode 100644 scroller_test.go diff --git a/README.md b/README.md index b82c1f6..147a4db 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ go get github.com/allisonhere/tideui ## Features - Nineteen built-in palettes with optional background, foreground, and accent overrides. -- `StackedRight` layout derived from Tide's three-pane reader and a general `ThreeColumn` layout. +- Five layout modes: `StackedRight`, `ThreeColumn`, `SidebarOnly`, `Tabbed`, and `Floating`. +- Per-pane scroll offsets with a `PaneScroller` helper for managing scroll state. +- Single-line `Row` and multi-line `Block` primitives for rendering themed list content. - Compact and comfortable density modes plus VT52 ASCII presentation. - Themed pane headers, rows, status bars, overlays, and a Tide-derived theme picker. - Terminal background sequences exposed for application-controlled terminal updates. @@ -46,8 +48,8 @@ view := renderer.Render(tideui.Layout{ Width: 80, Height: 24, Mode: tideui.StackedRight, Panes: [3]tideui.Pane{ {Title: "Projects", Content: "inbox\narchive", Focused: true}, - {Title: "Tasks", Content: "ship tideui"}, - {Title: "Detail", Content: "Application-owned content."}, + {Title: "Tasks", Content: "ship tideui"}, + {Title: "Detail", Content: "Application-owned content."}, }, Status: &tideui.StatusBar{Left: "ready", Right: "? help"}, }) @@ -77,34 +79,126 @@ func (m model) View() string { ## Layouts -`StackedRight` renders pane `0` as a left sidebar, pane `1` above pane `2` on -the right, and defaults to Tide's `28%` sidebar and `40%` upper-right -height. Configure it with `SidebarRatio` and `UpperRightRatio`. - -`ThreeColumn` renders the three panes from left to right. Use `ColumnRatios` to -allocate relative widths: +| Mode | Description | Configuration fields | +|---|---|---| +| `StackedRight` | Pane 0 as sidebar, panes 1 and 2 stacked on the right | `SidebarRatio`, `UpperRightRatio` | +| `ThreeColumn` | All three panes side by side | `ColumnRatios` | +| `SidebarOnly` | Pane 0 as sidebar, pane 1 as full-height main area (pane 2 unused) | `SidebarRatio` | +| `Tabbed` | Tab bar across the top; the focused pane's content fills the area below | — | +| `Floating` | Pane 0 as full-screen background; panes 1 and 2 as overlaid floating panels | `FloatWidthRatio`, `FloatHeightRatio` | ```go -layout.Mode = tideui.ThreeColumn +// StackedRight with custom ratios +layout.Mode = tideui.StackedRight +layout.SidebarRatio = 0.30 +layout.UpperRightRatio = 0.45 + +// ThreeColumn with relative widths +layout.Mode = tideui.ThreeColumn layout.ColumnRatios = [3]float64{2, 3, 5} + +// Floating panels +layout.Mode = tideui.Floating +layout.FloatWidthRatio = 0.40 // panels occupy 40 % of width +layout.FloatHeightRatio = 0.50 // split evenly between the two panels ``` -## Rows And Content +In `Tabbed` mode the `Focused` field on each `Pane` selects the active tab +(first focused pane wins; falls back to pane 0). + +## Rows and Blocks -Pane content is application-provided text. Use `RenderRow` for themed list rows -and the exported `Styles` for custom detail content: +### Single-line rows + +`RenderRow` renders a single-line list item with optional prefix and +right-aligned suffix. Use the `Selected` and `Muted` states for highlighting: ```go rows := []string{ renderer.RenderRow(tideui.Row{Prefix: "* ", Text: "Selected", Suffix: "3", Selected: true}, 26), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Normal"}, 26), renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Archived", Muted: true}, 26), } -detail := renderer.Styles.DetailTitle.Render("Selected") + "\n\n" + - renderer.Styles.DetailBody.Render("Application-owned detail content.") +``` + +### Multi-line blocks + +`RenderBlock` renders a structured item with a header line and an optional +multi-line body — useful for message threads, notification cards, or any content +richer than a single row. A `Block` with no `Body` produces byte-identical +output to the equivalent `RenderRow`. + +```go +blocks := []string{ + renderer.RenderBlock(tideui.Block{ + Prefix: "● ", Header: "alice", Meta: "10:02", + Body: "The UI toolkit is looking great.", + }, width), + renderer.RenderBlock(tideui.Block{ + Prefix: "● ", Header: "bob", Meta: "10:05", + Body: "Agreed — just added multi-line block support.", + Selected: true, + }, width), + renderer.RenderBlock(tideui.Block{ + Prefix: "○ ", Header: "alice", Meta: "10:07", + Body: "Does it support scrolling?", + Muted: true, + }, width), +} +``` + +The `Body` is indented to align with the header text start (after `Prefix`). +`Selected` and `Muted` apply to the header line; the body always uses +`DetailBody` styling. + +### Custom detail content + +Use the exported `Styles` for completely custom content inside a pane: + +```go +detail := renderer.Styles.DetailTitle.Render("Subject line") + "\n" + + renderer.Styles.DetailMeta.Render("alice · 10:02") + "\n\n" + + renderer.Styles.DetailBody.Render("Message body text goes here.") ``` Focused panes use the theme accent. Set `Pane.Accent` only when an individual -pane should intentionally use another accent color. +pane should intentionally override the accent color. + +## Scrollable Panes + +Set `Pane.ScrollOffset` to scroll a pane's content by that many lines. +`PaneScroller` is a convenience helper that manages the integer offset and +exposes scroll actions: + +```go +type model struct { + scrollers [3]tideui.PaneScroller + // ... +} + +// In Update: +case "j", "down": + m.scrollers[m.focus].ScrollDown(1) +case "k", "up": + m.scrollers[m.focus].ScrollUp(1) +case "g": + m.scrollers[m.focus].ScrollToTop() + +// In View — pass Offset() to each pane: +tideui.Pane{ + Title: "Tasks", + Content: strings.Join(rows, "\n"), + Focused: m.focus == 1, + ScrollOffset: m.scrollers[1].Offset(), +} +``` + +`ClampTo(totalLines, visibleLines)` prevents the scroller from going past the +last line when you know the content line count. The renderer silently clamps +out-of-range offsets regardless, so a blank pane is never produced. + +`CanScrollDown(totalLines, visibleLines)` reports whether more content is +hidden below, which is useful for rendering a scroll indicator. ## Themes @@ -130,7 +224,7 @@ Built-in theme names: `rose-pine-dawn`, `one-dark`, `magenta-geode`, `coral-sunset`, `lavender-fields-forever`, `vt100`, and `vt52`. -## Theme Pickers +## Theme Picker `ThemePicker` provides Tide-derived picker state and modal rendering: `j`/`k` and arrow keys preview themes, `enter` confirms, and `esc` reverts. @@ -178,7 +272,7 @@ func (m model) View() string { } ``` -## Status Bars And Overlays +## Status Bars and Overlays Provide a `StatusBar` and optional `Overlay` in the layout; `Width` on an overlay is the full modal width including its border: @@ -206,7 +300,8 @@ In v1, `tideui` renders presentation primitives. The consuming application owns: - Bubble Tea `Update` behavior and commands. - Application keyboard navigation and focus state outside the theme picker. -- Viewport scrolling and content formatting. +- Scroll offset state (via `PaneScroller` or directly via `Pane.ScrollOffset`). +- Content formatting and line counts for `ClampTo` / `CanScrollDown`. - Persisted theme configuration after picker confirmation. - Terminal control sequence output. @@ -221,6 +316,15 @@ go test ./... go vet ./... ``` -Run the demo with `go run ./cmd/demo`. Use `tab` to move focus, `l` to change -layout, `t` to open the theme picker, `d` to switch density, `o` to toggle the -generic overlay, and `q` to quit. +Run the demo with `go run ./cmd/demo`. + +| Key | Action | +|---|---| +| `tab` / `shift+tab` | Move focus between panes | +| `j` / `k` | Scroll the focused pane | +| `g` | Scroll to top | +| `l` | Cycle layout modes | +| `t` | Open theme picker | +| `d` | Toggle density | +| `o` | Toggle overlay | +| `q` | Quit | diff --git a/cmd/demo/main.go b/cmd/demo/main.go index 7fea78f..1c73dd0 100644 --- a/cmd/demo/main.go +++ b/cmd/demo/main.go @@ -18,6 +18,7 @@ type model struct { density tideui.Density mode tideui.LayoutMode showOverlay bool + scrollers [3]tideui.PaneScroller } func newModel() model { @@ -61,13 +62,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.density = tideui.Compact } case "l": - if m.mode == tideui.StackedRight { + switch m.mode { + case tideui.StackedRight: m.mode = tideui.ThreeColumn - } else { + case tideui.ThreeColumn: + m.mode = tideui.SidebarOnly + case tideui.SidebarOnly: + m.mode = tideui.Tabbed + case tideui.Tabbed: + m.mode = tideui.Floating + default: m.mode = tideui.StackedRight } case "o": m.showOverlay = !m.showOverlay + case "j", "down": + m.scrollers[m.focus].ScrollDown(1) + case "k", "up": + m.scrollers[m.focus].ScrollUp(1) + case "g": + m.scrollers[m.focus].ScrollToTop() } } return m, nil @@ -78,23 +92,60 @@ func (m model) View() string { return "" } renderer := tideui.NewRenderer(m.theme, tideui.StyleOptions{Density: m.density}) - styles := renderer.Styles + boardWidth := 20 items := []string{ - renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Inbox", Suffix: "8", Selected: m.focus == 0}, 20), - renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Projects"}, 20), - renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Archive", Muted: true}, 20), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Inbox", Suffix: "8", Selected: m.focus == 0}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Projects"}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Archive", Muted: true}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Drafts", Suffix: "2"}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Sent"}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Spam", Muted: true}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Trash", Muted: true}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Work"}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Personal"}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Reading list"}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Bookmarks", Suffix: "5"}, boardWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Starred"}, boardWidth), } + taskWidth := 28 tasks := []string{ - renderer.RenderRow(tideui.Row{Prefix: "* ", Text: "Extract UI toolkit", Selected: m.focus == 1}, 28), - renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Publish module", Muted: true}, 28), + renderer.RenderRow(tideui.Row{Prefix: "* ", Text: "Extract UI toolkit", Selected: m.focus == 1}, taskWidth), + renderer.RenderRow(tideui.Row{Prefix: "* ", Text: "Write tests"}, taskWidth), + renderer.RenderRow(tideui.Row{Prefix: "* ", Text: "Add scroll support"}, taskWidth), + renderer.RenderRow(tideui.Row{Prefix: "* ", Text: "Theme picker"}, taskWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Publish module", Muted: true}, taskWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Write README", Muted: true}, taskWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Add examples", Muted: true}, taskWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Tag release", Muted: true}, taskWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Announce", Muted: true}, taskWidth), + renderer.RenderRow(tideui.Row{Prefix: " ", Text: "Update docs", Muted: true}, taskWidth), + } + messages := []tideui.Block{ + {Prefix: "● ", Header: "alice", Meta: "09:41", Body: "The UI toolkit is looking great — themes and layout modes are working perfectly."}, + {Prefix: "● ", Header: "bob", Meta: "09:43", Body: "Agreed. Just added multi-line block support so message threads like this one render cleanly.", Selected: m.focus == 2}, + {Prefix: "○ ", Header: "alice", Meta: "09:45", Body: "Does it handle scrolling? Long threads should scroll per-pane.", Muted: true}, + {Prefix: "○ ", Header: "bob", Meta: "09:46", Body: "Yes — use j/k to scroll any pane. The offset is owned by the app, not the library.", Muted: true}, + {Prefix: "○ ", Header: "alice", Meta: "09:48", Body: "Nice. And density?", Muted: true}, + {Prefix: "○ ", Header: "bob", Meta: "09:49", Body: "Press d to toggle compact vs comfortable spacing. Both modes work for blocks.", Muted: true}, + {Prefix: "○ ", Header: "alice", Meta: "09:51", Body: "What about the floating and tabbed layouts?", Muted: true}, + {Prefix: "○ ", Header: "bob", Meta: "09:52", Body: "Press l to cycle through all five. Floating panels sit over pane 0. Tabbed shows one pane at a time.", Muted: true}, } - detail := styles.DetailTitle.Render("Extract UI toolkit") + "\n\n" + - styles.DetailBody.Render("This content is owned by the application.\nThe library supplies its shell and theme.") + detail := strings.Join(func() []string { + out := make([]string, len(messages)) + for i, b := range messages { + out[i] = renderer.RenderBlock(b, 44) + } + return out + }(), "\n") - layoutName := "stacked-right" - if m.mode == tideui.ThreeColumn { - layoutName = "three-column" + layoutNames := map[tideui.LayoutMode]string{ + tideui.StackedRight: "stacked-right", + tideui.ThreeColumn: "three-column", + tideui.SidebarOnly: "sidebar-only", + tideui.Tabbed: "tabbed", + tideui.Floating: "floating", } + layoutName := layoutNames[m.mode] var modal *tideui.Overlay if m.picker.Opened() { pickerModal := m.picker.Modal(renderer, 42, m.height) @@ -111,13 +162,13 @@ func (m model) View() string { return renderer.Render(tideui.Layout{ Width: m.width, Height: m.height, Mode: m.mode, Panes: [3]tideui.Pane{ - {Title: "Boards", Hint: "tab focus", Content: strings.Join(items, "\n"), Focused: m.focus == 0}, - {Title: "Tasks", Hint: "t themes", Content: strings.Join(tasks, "\n"), Focused: m.focus == 1}, - {Title: "Detail", Hint: "o modal", Content: detail, Focused: m.focus == 2}, + {Title: "Boards", Hint: "tab focus", Content: strings.Join(items, "\n"), Focused: m.focus == 0, ScrollOffset: m.scrollers[0].Offset()}, + {Title: "Tasks", Hint: "t themes", Content: strings.Join(tasks, "\n"), Focused: m.focus == 1, ScrollOffset: m.scrollers[1].Offset()}, + {Title: "Detail", Hint: "o modal", Content: detail, Focused: m.focus == 2, ScrollOffset: m.scrollers[2].Offset()}, }, Status: &tideui.StatusBar{ - Left: fmt.Sprintf("%s | %s | %s", m.theme.Name, m.density, layoutName), - Right: "tab focus t themes d density l layout o overlay q quit", + Left: fmt.Sprintf("%s %s %s", m.theme.Name, m.density, layoutName), + Right: "tab j/k scroll g top t d l o q", }, Modal: modal, }) diff --git a/layout.go b/layout.go index 2119603..82f4e6b 100644 --- a/layout.go +++ b/layout.go @@ -15,15 +15,22 @@ const ( StackedRight LayoutMode = iota // ThreeColumn places all three panes side by side. ThreeColumn + // SidebarOnly places pane 0 as a full-height sidebar and pane 1 as the main area. Pane 2 is unused. + SidebarOnly + // Tabbed renders a tab bar across the top and shows the focused pane's content below. + Tabbed + // Floating renders pane 0 as a full-screen background with panes 1 and 2 as overlaid floating panels. + Floating ) // Pane supplies a header and rendered body for one region of a Layout. type Pane struct { - Title string - Hint string - Content string - Focused bool - Accent lipgloss.Color + Title string + Hint string + Content string + Focused bool + Accent lipgloss.Color + ScrollOffset int // lines to skip from the top of Content; renderer silently clamps to a valid range } // StatusBar supplies optional left- and right-aligned footer text. @@ -50,12 +57,16 @@ type Layout struct { Status *StatusBar Modal *Overlay - // SidebarRatio controls pane 0 in StackedRight mode. Zero uses 0.28. + // SidebarRatio controls pane 0 in StackedRight and SidebarOnly modes. Zero uses 0.28. SidebarRatio float64 // UpperRightRatio controls pane 1 in StackedRight mode. Zero uses 0.40. UpperRightRatio float64 // ColumnRatios controls widths in ThreeColumn mode. Zero values use equal columns. ColumnRatios [3]float64 + // FloatWidthRatio controls the width of floating panels in Floating mode. Zero uses 0.45. + FloatWidthRatio float64 + // FloatHeightRatio controls the height split of the two floating panels in Floating mode. Zero uses 0.50. + FloatHeightRatio float64 } // Renderer renders Layout and Row values using one resolved set of styles. @@ -83,6 +94,12 @@ func (r Renderer) Render(layout Layout) string { switch layout.Mode { case ThreeColumn: main = r.renderThreeColumn(layout.Panes, layout.Width, mainHeight, layout.ColumnRatios) + case SidebarOnly: + main = r.renderSidebarOnly(layout.Panes, layout.Width, mainHeight, layout.SidebarRatio) + case Tabbed: + main = r.renderTabbed(layout.Panes, layout.Width, mainHeight) + case Floating: + main = r.renderFloating(layout.Panes, layout.Width, mainHeight, layout.FloatWidthRatio, layout.FloatHeightRatio) default: main = r.renderStackedRight(layout.Panes, layout.Width, mainHeight, layout.SidebarRatio, layout.UpperRightRatio) } @@ -117,6 +134,54 @@ func (r Renderer) RenderRow(row Row, width int) string { return style.Width(width).Render(alignRow(row.Prefix, row.Text, row.Suffix, width)) } +// Block is a multi-line themed item with an optional body below the header line. +// A Block with no Body is byte-identical to the equivalent RenderRow output. +type Block struct { + Prefix string // left margin applied to the header; body is indented to the same column + Header string // primary header text + Meta string // right-aligned annotation on the header line + Body string // optional multi-line body below the header + Selected bool + Muted bool +} + +// RenderBlock formats one Block to the requested content width. +// The header uses Item styles (including density spacing). When Body is present, +// the header's bottom padding is removed so density spacing does not create a gap +// between the header and body. The body is indented to align with the header text. +func (r Renderer) RenderBlock(block Block, width int) string { + if width <= 0 { + return "" + } + style := r.Styles.Item + if block.Muted { + style = r.Styles.ItemMuted + } + if block.Selected { + style = r.Styles.ItemSelected + } + if block.Body == "" { + return style.Width(width).Render(alignRow(block.Prefix, block.Header, block.Meta, width)) + } + headerLine := style.Copy().UnsetPaddingBottom().Width(width). + Render(alignRow(block.Prefix, block.Header, block.Meta, width)) + prefixWidth := lipgloss.Width(block.Prefix) + bodyContent := r.Styles.DetailBody.Copy().PaddingLeft(prefixWidth). + Width(width).Render(block.Body) + return headerLine + "\n" + bodyContent +} + +type paneBorders struct{ top, right, bottom, left bool } + +func applyScrollOffset(content string, offset int) string { + if offset <= 0 { + return content + } + lines := strings.Split(content, "\n") + skip := min(offset, max(0, len(lines)-1)) + return strings.Join(lines[skip:], "\n") +} + func (r Renderer) renderStackedRight(panes [3]Pane, width, height int, sidebarRatio, upperRatio float64) string { if sidebarRatio <= 0 || sidebarRatio >= 1 { sidebarRatio = 0.28 @@ -129,10 +194,10 @@ func (r Renderer) renderStackedRight(panes [3]Pane, width, height int, sidebarRa upperHeight := ratioSize(height, upperRatio) lowerHeight := height - upperHeight - left := r.renderPane(panes[0], sidebarWidth, height, true, false) + left := r.renderPane(panes[0], sidebarWidth, height, paneBorders{right: true}) right := lipgloss.JoinVertical(lipgloss.Left, - r.renderPane(panes[1], rightWidth, upperHeight, false, true), - r.renderPane(panes[2], rightWidth, lowerHeight, false, false), + r.renderPane(panes[1], rightWidth, upperHeight, paneBorders{bottom: true}), + r.renderPane(panes[2], rightWidth, lowerHeight, paneBorders{}), ) return lipgloss.JoinHorizontal(lipgloss.Top, left, right) } @@ -140,25 +205,88 @@ func (r Renderer) renderStackedRight(panes [3]Pane, width, height int, sidebarRa func (r Renderer) renderThreeColumn(panes [3]Pane, width, height int, ratios [3]float64) string { widths := columnSizes(width, ratios) return lipgloss.JoinHorizontal(lipgloss.Top, - r.renderPane(panes[0], widths[0], height, true, false), - r.renderPane(panes[1], widths[1], height, true, false), - r.renderPane(panes[2], widths[2], height, false, false), + r.renderPane(panes[0], widths[0], height, paneBorders{right: true}), + r.renderPane(panes[1], widths[1], height, paneBorders{right: true}), + r.renderPane(panes[2], widths[2], height, paneBorders{}), ) } -func (r Renderer) renderPane(pane Pane, width, height int, rightBorder, bottomBorder bool) string { +func (r Renderer) renderSidebarOnly(panes [3]Pane, width, height int, sidebarRatio float64) string { + if sidebarRatio <= 0 || sidebarRatio >= 1 { + sidebarRatio = 0.28 + } + sidebarWidth := ratioSize(width, sidebarRatio) + mainWidth := width - sidebarWidth + left := r.renderPane(panes[0], sidebarWidth, height, paneBorders{right: true}) + right := r.renderPane(panes[1], mainWidth, height, paneBorders{}) + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) +} + +func (r Renderer) renderTabbed(panes [3]Pane, width, height int) string { + activeIdx := 0 + for i, p := range panes { + if p.Focused { + activeIdx = i + break + } + } + tabWidth := width / 3 + tab0 := r.renderHeader(panes[0], tabWidth) + tab1 := r.renderHeader(panes[1], tabWidth) + tab2 := r.renderHeader(panes[2], max(1, width-2*tabWidth)) + tabBar := lipgloss.JoinHorizontal(lipgloss.Top, tab0, tab1, tab2) + + contentHeight := max(0, height-1) + if contentHeight == 0 { + return tabBar + } + bodyContent := r.Styles.DetailBody.Width(width).Render(panes[activeIdx].Content) + bodyContent = applyScrollOffset(bodyContent, panes[activeIdx].ScrollOffset) + body := clampView(bodyContent, width, contentHeight, r.Styles.Theme.Bg) + return lipgloss.JoinVertical(lipgloss.Left, tabBar, body) +} + +func (r Renderer) renderFloating(panes [3]Pane, width, height int, floatWidthRatio, floatHeightRatio float64) string { + if floatWidthRatio <= 0 || floatWidthRatio >= 1 { + floatWidthRatio = 0.45 + } + if floatHeightRatio <= 0 || floatHeightRatio >= 1 { + floatHeightRatio = 0.50 + } + panelWidth := ratioSize(width, floatWidthRatio) + panel1Height := ratioSize(height, floatHeightRatio) + panel2Height := height - panel1Height + + bg := r.renderPane(panes[0], width, height, paneBorders{}) + p1 := r.renderPane(panes[1], panelWidth, panel1Height, paneBorders{top: true, right: true, bottom: true, left: true}) + p2 := r.renderPane(panes[2], panelWidth, panel2Height, paneBorders{top: true, right: true, bottom: true, left: true}) + + x := max(0, width-panelWidth) + view := placeBoxAt(bg, p1, x, 0, width, height, r.Styles.Theme.Bg) + return placeBoxAt(view, p2, x, panel1Height, width, height, r.Styles.Theme.Bg) +} + +func (r Renderer) renderPane(pane Pane, width, height int, borders paneBorders) string { if width <= 0 || height <= 0 { return "" } // Keep separators inside their assigned region on constrained terminals. - rightBorder = rightBorder && width > 1 - bottomBorder = bottomBorder && height > 1 + borders.right = borders.right && width > 1 + borders.left = borders.left && width > 1 + borders.bottom = borders.bottom && height > 1 + borders.top = borders.top && height > 1 innerWidth := width innerHeight := height - if rightBorder { + if borders.right { + innerWidth-- + } + if borders.left { innerWidth-- } - if bottomBorder { + if borders.bottom { + innerHeight-- + } + if borders.top { innerHeight-- } innerWidth = max(1, innerWidth) @@ -167,6 +295,7 @@ func (r Renderer) renderPane(pane Pane, width, height int, rightBorder, bottomBo contentHeight := max(0, innerHeight-1) header := r.renderHeader(pane, innerWidth) bodyContent := r.Styles.DetailBody.Width(innerWidth).Render(pane.Content) + bodyContent = applyScrollOffset(bodyContent, pane.ScrollOffset) body := clampView(bodyContent, innerWidth, contentHeight, r.Styles.Theme.Bg) content := header if contentHeight > 0 { @@ -181,7 +310,7 @@ func (r Renderer) renderPane(pane Pane, width, height int, rightBorder, bottomBo } } return r.Styles.Pane.Copy(). - Border(paneBorder(r.Styles.PlainUI), false, rightBorder, bottomBorder, false). + Border(paneBorder(r.Styles.PlainUI), borders.top, borders.right, borders.bottom, borders.left). BorderForeground(borderColor). Width(innerWidth). Height(innerHeight). @@ -315,7 +444,6 @@ func clampView(view string, width, height int, background lipgloss.Color) string } func overlayOnBase(base, box string, width, height int, background lipgloss.Color) string { - base = clampView(base, width, height, background) boxLines := strings.Split(box, "\n") boxWidth := 0 for _, line := range boxLines { @@ -323,14 +451,24 @@ func overlayOnBase(base, box string, width, height int, background lipgloss.Colo } x := max(0, (width-boxWidth)/2) y := max(0, (height-len(boxLines))/2) + return placeBoxAt(base, box, x, y, width, height, background) +} + +func placeBoxAt(base, box string, x, y, totalWidth, totalHeight int, bg lipgloss.Color) string { + base = clampView(base, totalWidth, totalHeight, bg) + boxLines := strings.Split(box, "\n") + boxWidth := 0 + for _, line := range boxLines { + boxWidth = max(boxWidth, lipgloss.Width(line)) + } lines := strings.Split(base, "\n") for i, line := range boxLines { target := y + i - if target >= height { - break + if target < 0 || target >= totalHeight { + continue } left := ansi.Cut(lines[target], 0, x) - right := ansi.Cut(lines[target], min(width, x+boxWidth), width) + right := ansi.Cut(lines[target], min(totalWidth, x+boxWidth), totalWidth) lines[target] = left + line + right } return strings.Join(lines, "\n") diff --git a/layout_test.go b/layout_test.go index df00de5..b9a3fa1 100644 --- a/layout_test.go +++ b/layout_test.go @@ -97,9 +97,111 @@ func TestOverlayTitleStaysInsideRequestedModalWidth(t *testing.T) { } } +func TestRenderBlockNoBodyMatchesRenderRow(t *testing.T) { + for _, density := range []Density{Compact, Comfortable} { + renderer := NewRenderer(CatppuccinMocha, StyleOptions{Density: density}) + row := renderer.RenderRow(Row{Prefix: "* ", Text: "Hello", Suffix: "3", Selected: true}, 40) + block := renderer.RenderBlock(Block{Prefix: "* ", Header: "Hello", Meta: "3", Selected: true}, 40) + if row != block { + t.Fatalf("density=%s: block with no body should match row\nrow: %q\nblock: %q", density, row, block) + } + } +} + +func TestRenderBlockWithBodyFitsWidth(t *testing.T) { + renderer := NewRenderer(Nord, StyleOptions{Density: Compact}) + block := renderer.RenderBlock(Block{ + Prefix: "● ", Header: "alice", Meta: "10:02", + Body: "This is a longer message that should wrap correctly to fit within the block width.", + }, 40) + for i, line := range strings.Split(block, "\n") { + if got := lipgloss.Width(line); got != 40 { + t.Fatalf("block line %d width = %d, want 40 (%q)", i, got, ansi.Strip(line)) + } + } +} + +func TestRenderBlockBodyIsIndentedByPrefix(t *testing.T) { + renderer := NewRenderer(Dracula, StyleOptions{Density: Compact}) + const prefix = ">> " + block := renderer.RenderBlock(Block{ + Prefix: prefix, Header: "bob", Body: "Line one\nLine two", + }, 40) + lines := strings.Split(ansi.Strip(block), "\n") + if len(lines) < 2 { + t.Fatalf("expected at least 2 lines, got %d", len(lines)) + } + wantIndent := strings.Repeat(" ", lipgloss.Width(prefix)) + for _, line := range lines[1:] { + if !strings.HasPrefix(line, wantIndent) { + t.Fatalf("body line %q does not start with %q-char indent", line, wantIndent) + } + } +} + +func TestRenderBlockStateRoutingMatchesRenderRow(t *testing.T) { + // ANSI codes are stripped in non-TTY test environments, so we verify that + // RenderBlock routes Selected/Muted identically to RenderRow by comparing + // their outputs directly (they share the same style-selection code path). + renderer := NewRenderer(CatppuccinMocha, StyleOptions{Density: Compact}) + for _, tc := range []struct{ selected, muted bool }{ + {false, false}, {true, false}, {false, true}, + } { + got := renderer.RenderBlock(Block{Prefix: " ", Header: "x", Selected: tc.selected, Muted: tc.muted}, 30) + want := renderer.RenderRow(Row{Prefix: " ", Text: "x", Selected: tc.selected, Muted: tc.muted}, 30) + if got != want { + t.Fatalf("selected=%v muted=%v: RenderBlock routing does not match RenderRow\ngot: %q\nwant: %q", + tc.selected, tc.muted, got, want) + } + } +} + +func TestRenderSidebarOnlyFitsWindow(t *testing.T) { + renderer := NewRenderer(CatppuccinMocha, StyleOptions{Density: Compact}) + view := renderer.Render(testLayout(SidebarOnly)) + assertDimensions(t, view, 72, 20) + plain := ansi.Strip(view) + for _, title := range []string{"Sidebar", "List"} { + if !strings.Contains(plain, title) { + t.Fatalf("expected output to contain %q in:\n%s", title, plain) + } + } + if strings.Contains(plain, "Detail") { + t.Fatal("pane 2 title should not appear in SidebarOnly mode") + } +} + +func TestRenderTabbedFitsWindow(t *testing.T) { + renderer := NewRenderer(Nord, StyleOptions{Density: Compact}) + layout := testLayout(Tabbed) + view := renderer.Render(layout) + assertDimensions(t, view, 72, 20) + plain := ansi.Strip(view) + for _, title := range []string{"Sidebar", "List", "Detail"} { + if !strings.Contains(plain, title) { + t.Fatalf("expected tab bar to contain %q in:\n%s", title, plain) + } + } + if !strings.Contains(plain, "one") { + t.Fatal("expected active pane content to be visible") + } +} + +func TestRenderFloatingFitsWindow(t *testing.T) { + renderer := NewRenderer(Dracula, StyleOptions{Density: Compact}) + view := renderer.Render(testLayout(Floating)) + assertDimensions(t, view, 72, 20) + plain := ansi.Strip(view) + for _, title := range []string{"Sidebar", "List", "Detail"} { + if !strings.Contains(plain, title) { + t.Fatalf("expected output to contain %q in:\n%s", title, plain) + } + } +} + func TestRenderConstrainedWindowsNeverExceedsDimensions(t *testing.T) { renderer := NewRenderer(CatppuccinMocha, StyleOptions{Density: Compact}) - for _, mode := range []LayoutMode{StackedRight, ThreeColumn} { + for _, mode := range []LayoutMode{StackedRight, ThreeColumn, SidebarOnly, Tabbed, Floating} { for width := 1; width <= 12; width++ { for height := 1; height <= 8; height++ { layout := testLayout(mode) @@ -124,6 +226,48 @@ func TestThreeColumnRatiosAlwaysReserveEachPaneWhenSpaceAllows(t *testing.T) { } } +func TestRenderTabbedScrollOffsetSkipsLines(t *testing.T) { + renderer := NewRenderer(CatppuccinMocha, StyleOptions{Density: Compact}) + layout := testLayout(Tabbed) + layout.Panes[0].Focused = true + layout.Panes[0].Content = "line-A\nline-B\nline-C\nline-D\nline-E" + layout.Panes[0].ScrollOffset = 2 + view := renderer.Render(layout) + assertDimensions(t, view, 72, 20) + plain := ansi.Strip(view) + if strings.Contains(plain, "line-A") || strings.Contains(plain, "line-B") { + t.Fatal("scrolled-past lines should not appear in tabbed view") + } + if !strings.Contains(plain, "line-C") { + t.Fatal("first visible line after scroll offset should appear in tabbed view") + } +} + +func TestRenderPaneScrollOffsetSkipsLines(t *testing.T) { + renderer := NewRenderer(CatppuccinMocha, StyleOptions{Density: Compact}) + layout := testLayout(StackedRight) + layout.Panes[0].Content = "line-A\nline-B\nline-C\nline-D\nline-E" + layout.Panes[0].ScrollOffset = 2 + view := renderer.Render(layout) + assertDimensions(t, view, 72, 20) + plain := ansi.Strip(view) + if strings.Contains(plain, "line-A") || strings.Contains(plain, "line-B") { + t.Fatal("scrolled-past lines should not appear in the view") + } + if !strings.Contains(plain, "line-C") { + t.Fatal("first visible line after scroll offset should appear") + } +} + +func TestRenderPaneScrollOffsetOutOfRangeDoesNotPanic(t *testing.T) { + renderer := NewRenderer(CatppuccinMocha, StyleOptions{Density: Compact}) + layout := testLayout(StackedRight) + layout.Panes[1].Content = "only\ntwo\nlines" + layout.Panes[1].ScrollOffset = 999 + view := renderer.Render(layout) + assertDimensions(t, view, 72, 20) +} + func TestTerminalBackgroundSequenceDoesNotWriteTerminal(t *testing.T) { set, reset := TerminalBackgroundSequences(CatppuccinMocha) if !strings.Contains(set, string(CatppuccinMocha.Bg)) || reset == "" { diff --git a/scroller.go b/scroller.go new file mode 100644 index 0000000..8310df0 --- /dev/null +++ b/scroller.go @@ -0,0 +1,31 @@ +package tideui + +// PaneScroller manages a scroll offset for a Pane. The application holds one +// scroller per pane and passes Offset() to Pane.ScrollOffset each render. +type PaneScroller struct { + offset int +} + +// Offset returns the current scroll offset to set on Pane.ScrollOffset. +func (s PaneScroller) Offset() int { return s.offset } + +// ScrollDown moves the view down by n lines. +func (s *PaneScroller) ScrollDown(n int) { s.offset += n } + +// ScrollUp moves the view up by n lines, stopping at the top. +func (s *PaneScroller) ScrollUp(n int) { s.offset = max(0, s.offset-n) } + +// ScrollToTop resets the scroll position to the beginning. +func (s *PaneScroller) ScrollToTop() { s.offset = 0 } + +// ClampTo limits the offset so the view never scrolls past the last line. +// totalLines is the number of lines in the pane content; visibleLines is the +// number of lines the pane can display (pane height minus the header row). +func (s *PaneScroller) ClampTo(totalLines, visibleLines int) { + s.offset = min(s.offset, max(0, totalLines-visibleLines)) +} + +// CanScrollDown reports whether content is hidden below the current view. +func (s PaneScroller) CanScrollDown(totalLines, visibleLines int) bool { + return s.offset < max(0, totalLines-visibleLines) +} diff --git a/scroller_test.go b/scroller_test.go new file mode 100644 index 0000000..75608b2 --- /dev/null +++ b/scroller_test.go @@ -0,0 +1,60 @@ +package tideui + +import "testing" + +func TestPaneScrollerScrollDownUp(t *testing.T) { + var s PaneScroller + s.ScrollDown(3) + if s.Offset() != 3 { + t.Fatalf("offset = %d, want 3", s.Offset()) + } + s.ScrollUp(1) + if s.Offset() != 2 { + t.Fatalf("offset = %d, want 2", s.Offset()) + } + s.ScrollUp(100) + if s.Offset() != 0 { + t.Fatalf("ScrollUp past top: offset = %d, want 0", s.Offset()) + } +} + +func TestPaneScrollerScrollToTop(t *testing.T) { + var s PaneScroller + s.ScrollDown(10) + s.ScrollToTop() + if s.Offset() != 0 { + t.Fatalf("offset after ScrollToTop = %d, want 0", s.Offset()) + } +} + +func TestPaneScrollerClampTo(t *testing.T) { + var s PaneScroller + s.ScrollDown(50) + s.ClampTo(20, 10) // 20 total lines, 10 visible → max offset = 10 + if s.Offset() != 10 { + t.Fatalf("offset after ClampTo(20,10) = %d, want 10", s.Offset()) + } + s.ClampTo(5, 10) // content shorter than view → max offset = 0 + if s.Offset() != 0 { + t.Fatalf("offset after ClampTo(5,10) = %d, want 0", s.Offset()) + } +} + +func TestPaneScrollerCanScrollDown(t *testing.T) { + var s PaneScroller + if s.CanScrollDown(5, 10) { + t.Fatal("CanScrollDown should be false when content fits in view") + } + if !s.CanScrollDown(15, 10) { + t.Fatal("CanScrollDown should be true when content exceeds view") + } + s.ScrollDown(5) + if s.CanScrollDown(15, 10) { + // offset=5, max=5 → at bottom + t.Fatal("CanScrollDown should be false when at bottom") + } + if !s.CanScrollDown(16, 10) { + // offset=5, max=6 → one more line available + t.Fatal("CanScrollDown should be true when one more line is available") + } +} diff --git a/styles.go b/styles.go index c3aaf27..a89ccf8 100644 --- a/styles.go +++ b/styles.go @@ -19,37 +19,51 @@ type StyleOptions struct { } // Styles exposes resolved Lipgloss styles for composing application content. +// All styles carry the correct theme background so rendering any piece of +// content inside a pane produces a cohesive, uniformly coloured surface. type Styles struct { - Theme Theme - PlainUI bool + Theme Theme // resolved theme after any ThemeOverrides are applied + PlainUI bool // true when the theme uses ASCII borders (e.g. VT52) Density Density - Pane lipgloss.Style - PaneHeaderActive lipgloss.Style - PaneHeaderInactive lipgloss.Style - Item lipgloss.Style - ItemMuted lipgloss.Style - ItemSelected lipgloss.Style - Badge lipgloss.Style - DetailTitle lipgloss.Style - DetailMeta lipgloss.Style - DetailBody lipgloss.Style - DetailFocusLine lipgloss.Style - SearchMatch lipgloss.Style - - StatusBar lipgloss.Style - StatusError lipgloss.Style - StatusHint lipgloss.Style + // Pane chrome — used internally; available for custom pane-like surfaces. + Pane lipgloss.Style // pane background fill + PaneHeaderActive lipgloss.Style // focused pane title bar + PaneHeaderInactive lipgloss.Style // unfocused pane title bar + + // List items — pass to RenderRow or use directly for custom rows. + Item lipgloss.Style // default list row + ItemMuted lipgloss.Style // de-emphasised row (archived, read, disabled) + ItemSelected lipgloss.Style // currently highlighted row + + // Inline annotations. + Badge lipgloss.Style // unread count or short tag rendered inside a row + SearchMatch lipgloss.Style // highlighted substring within a search result + + // Detail pane content — compose these to build multi-section detail views. + DetailTitle lipgloss.Style // bold accent-background heading (e.g. subject line) + DetailMeta lipgloss.Style // dimmed italic secondary line (e.g. author, date) + DetailBody lipgloss.Style // plain body text; wraps to Width when set + DetailFocusLine lipgloss.Style // subtle highlight for the cursor row in a detail list + + // Status bar segments — render text then join with StatusBarSeparator. + StatusBar lipgloss.Style // main status bar background and text + StatusError lipgloss.Style // error message segment (bold, error colour) + StatusHint lipgloss.Style // low-contrast keyboard hint + StatusNotice lipgloss.Style // accent-background announcement segment + // StatusBarJoiner renders the separator returned by StatusBarSeparator. StatusBarJoiner lipgloss.Style - StatusNotice lipgloss.Style - - Overlay lipgloss.Style - OverlayTitle lipgloss.Style - OverlayBody lipgloss.Style - OverlayHint lipgloss.Style - InputFocused lipgloss.Style - InputIdle lipgloss.Style - InputLabel lipgloss.Style + + // Overlay / modal content — used by renderOverlay; available for custom modals. + Overlay lipgloss.Style // modal border and background + OverlayTitle lipgloss.Style // accent-background modal heading + OverlayBody lipgloss.Style // modal body text + OverlayHint lipgloss.Style // dimmed footer hint inside the modal + + // Form inputs — for text fields rendered inside an overlay. + InputFocused lipgloss.Style // text field with focus border + InputIdle lipgloss.Style // text field without focus + InputLabel lipgloss.Style // label above a text field } func normalizeDensity(d Density) Density { diff --git a/theme.go b/theme.go index 46daf91..781bb45 100644 --- a/theme.go +++ b/theme.go @@ -11,19 +11,19 @@ const ( // Theme contains the semantic colors used by the shell and its components. type Theme struct { - Name string - Bg lipgloss.Color - Fg lipgloss.Color - Border lipgloss.Color - BorderFocus lipgloss.Color - Selected lipgloss.Color - Unread lipgloss.Color - Dimmed lipgloss.Color - StatusBar lipgloss.Color - StatusFg lipgloss.Color - Error lipgloss.Color - Overlay lipgloss.Color - OverlayBorder lipgloss.Color + Name string // identifier used by ThemeByName and ThemePicker + Bg lipgloss.Color // pane background + Fg lipgloss.Color // primary text + Border lipgloss.Color // unfocused pane border and inactive tab header background + BorderFocus lipgloss.Color // focused pane border, active tab header background, and accent highlight + Selected lipgloss.Color // selected list item background (falls back to BorderFocus) + Unread lipgloss.Color // badge / unread-count foreground + Dimmed lipgloss.Color // muted text; the renderer raises contrast if the raw value fails WCAG 3:1 + StatusBar lipgloss.Color // status bar background + StatusFg lipgloss.Color // status bar text + Error lipgloss.Color // error text in the status bar + Overlay lipgloss.Color // modal background; defaults to a lightness-adjusted Bg when empty + OverlayBorder lipgloss.Color // modal border; falls back to Border when empty } // ThemeOverrides replaces presentation colors without requiring a new theme.