Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 126 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"},
})
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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 |
87 changes: 69 additions & 18 deletions cmd/demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type model struct {
density tideui.Density
mode tideui.LayoutMode
showOverlay bool
scrollers [3]tideui.PaneScroller
}

func newModel() model {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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,
})
Expand Down
Loading
Loading