Detailed changes
@@ -86,135 +86,6 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
return buf.Render()
}
-// RenderWithHighlight renders content with optional focus styling and highlighting.
-// This is a helper that combines common rendering logic for all items.
-// The content parameter should be the raw rendered content before focus styling.
-// The style parameter should come from CurrentStyle() and may be nil.
-// func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string {
-// // Apply focus/blur styling if configured
-// rendered := content
-// if style != nil {
-// rendered = style.Render(rendered)
-// }
-//
-// if !b.HasHighlight() {
-// return rendered
-// }
-//
-// height := lipgloss.Height(rendered)
-//
-// // Create temp buffer to draw content with highlighting
-// tempBuf := uv.NewScreenBuffer(width, height)
-//
-// // Draw the rendered content to temp buffer
-// styled := uv.NewStyledString(rendered)
-// styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
-//
-// // Apply highlighting if active
-// b.ApplyHighlight(&tempBuf, width, height, style)
-//
-// return tempBuf.Render()
-// }
-
-// ApplyHighlight applies highlighting to a screen buffer.
-// This should be called after drawing content to the buffer.
-// func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
-// if b.highlightStartLine < 0 {
-// return
-// }
-//
-// var (
-// topMargin, topBorder, topPadding int
-// rightMargin, rightBorder, rightPadding int
-// bottomMargin, bottomBorder, bottomPadding int
-// leftMargin, leftBorder, leftPadding int
-// )
-// if style != nil {
-// topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
-// topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
-// style.GetBorderRightSize(),
-// style.GetBorderBottomSize(),
-// style.GetBorderLeftSize()
-// topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
-// }
-//
-// slog.Info("Applying highlight",
-// "highlightStartLine", b.highlightStartLine,
-// "highlightStartCol", b.highlightStartCol,
-// "highlightEndLine", b.highlightEndLine,
-// "highlightEndCol", b.highlightEndCol,
-// "width", width,
-// "height", height,
-// "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin),
-// "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder),
-// "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding),
-// )
-//
-// // Calculate content area offsets
-// contentArea := image.Rectangle{
-// Min: image.Point{
-// X: leftMargin + leftBorder + leftPadding,
-// Y: topMargin + topBorder + topPadding,
-// },
-// Max: image.Point{
-// X: width - (rightMargin + rightBorder + rightPadding),
-// Y: height - (bottomMargin + bottomBorder + bottomPadding),
-// },
-// }
-//
-// for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
-// if y >= buf.Height() {
-// break
-// }
-//
-// line := buf.Line(y)
-//
-// // Determine column range for this line
-// startCol := 0
-// if y == b.highlightStartLine {
-// startCol = min(b.highlightStartCol, len(line))
-// }
-//
-// endCol := len(line)
-// if y == b.highlightEndLine {
-// endCol = min(b.highlightEndCol, len(line))
-// }
-//
-// // Track last non-empty position as we go
-// lastContentX := -1
-//
-// // Single pass: check content and track last non-empty position
-// for x := startCol; x < endCol; x++ {
-// cell := line.At(x)
-// if cell == nil {
-// continue
-// }
-//
-// // Update last content position if non-empty
-// if cell.Content != "" && cell.Content != " " {
-// lastContentX = x
-// }
-// }
-//
-// // Only apply highlight up to last content position
-// highlightEnd := endCol
-// if lastContentX >= 0 {
-// highlightEnd = lastContentX + 1
-// } else if lastContentX == -1 {
-// highlightEnd = startCol // No content on this line
-// }
-//
-// // Apply highlight style only to cells with content
-// for x := startCol; x < highlightEnd; x++ {
-// if !image.Pt(x, y).In(contentArea) {
-// continue
-// }
-// cell := line.At(x)
-// cell.Style = b.highlightStyle(cell.Style)
-// }
-// }
-// }
-
// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
return func(uv.Style) uv.Style {
@@ -1,275 +0,0 @@
-package list_test
-
-import (
- "fmt"
-
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/ui/list"
- uv "github.com/charmbracelet/ultraviolet"
-)
-
-// Example demonstrates basic list usage with string items.
-func Example_basic() {
- // Create some items
- items := []list.Item{
- list.NewStringItem("First item"),
- list.NewStringItem("Second item"),
- list.NewStringItem("Third item"),
- }
-
- // Create a list with options
- l := list.New(items...)
- l.SetSize(80, 10)
- l.SetSelected(0)
- if true {
- l.Focus()
- }
-
- // Draw to a screen buffer
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
- l.Draw(&screen, area)
-
- // Render to string
- output := screen.Render()
- fmt.Println(output)
-}
-
-// BorderedItem demonstrates a focusable item with borders.
-type BorderedItem struct {
- id string
- content string
- focused bool
- width int
-}
-
-func NewBorderedItem(id, content string) *BorderedItem {
- return &BorderedItem{
- id: id,
- content: content,
- width: 80,
- }
-}
-
-func (b *BorderedItem) ID() string {
- return b.id
-}
-
-func (b *BorderedItem) Height(width int) int {
- // Account for border (2 lines for top and bottom)
- b.width = width // Update width for rendering
- return lipgloss.Height(b.render())
-}
-
-func (b *BorderedItem) Draw(scr uv.Screen, area uv.Rectangle) {
- rendered := b.render()
- styled := uv.NewStyledString(rendered)
- styled.Draw(scr, area)
-}
-
-func (b *BorderedItem) render() string {
- style := lipgloss.NewStyle().
- Width(b.width-4).
- Padding(0, 1)
-
- if b.focused {
- style = style.
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("205"))
- } else {
- style = style.
- Border(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("240"))
- }
-
- return style.Render(b.content)
-}
-
-func (b *BorderedItem) Focus() {
- b.focused = true
-}
-
-func (b *BorderedItem) Blur() {
- b.focused = false
-}
-
-func (b *BorderedItem) IsFocused() bool {
- return b.focused
-}
-
-// Example demonstrates focusable items with borders.
-func Example_focusable() {
- // Create focusable items
- items := []list.Item{
- NewBorderedItem("1", "Focusable Item 1"),
- NewBorderedItem("2", "Focusable Item 2"),
- NewBorderedItem("3", "Focusable Item 3"),
- }
-
- // Create list with first item selected and focused
- l := list.New(items...)
- l.SetSize(80, 20)
- l.SetSelected(0)
- if true {
- l.Focus()
- }
-
- // Draw to screen
- screen := uv.NewScreenBuffer(80, 20)
- area := uv.Rect(0, 0, 80, 20)
- l.Draw(&screen, area)
-
- // The first item will have a colored border since it's focused
- output := screen.Render()
- fmt.Println(output)
-}
-
-// Example demonstrates dynamic item updates.
-func Example_dynamicUpdates() {
- items := []list.Item{
- list.NewStringItem("Item 1"),
- list.NewStringItem("Item 2"),
- }
-
- l := list.New(items...)
- l.SetSize(80, 10)
-
- // Draw initial state
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
- l.Draw(&screen, area)
-
- // Update an item
- l.UpdateItem(2, list.NewStringItem("Updated Item 2"))
-
- // Draw again - only changed item is re-rendered
- l.Draw(&screen, area)
-
- // Append a new item
- l.AppendItem(list.NewStringItem("New Item 3"))
-
- // Draw again - master buffer grows efficiently
- l.Draw(&screen, area)
-
- output := screen.Render()
- fmt.Println(output)
-}
-
-// Example demonstrates scrolling with a large list.
-func Example_scrolling() {
- // Create many items
- items := make([]list.Item, 100)
- for i := range items {
- items[i] = list.NewStringItem(
- fmt.Sprintf("Item %d", i),
- )
- }
-
- // Create list with small viewport
- l := list.New(items...)
- l.SetSize(80, 10)
- l.SetSelected(0)
-
- // Draw initial view (shows items 0-9)
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
- l.Draw(&screen, area)
-
- // Scroll down
- l.ScrollBy(5)
- l.Draw(&screen, area) // Now shows items 5-14
-
- // Jump to specific item
- l.ScrollToItem(50)
- l.Draw(&screen, area) // Now shows item 50 and neighbors
-
- // Scroll to bottom
- l.ScrollToBottom()
- l.Draw(&screen, area) // Now shows last 10 items
-
- output := screen.Render()
- fmt.Println(output)
-}
-
-// VariableHeightItem demonstrates items with different heights.
-type VariableHeightItem struct {
- id string
- lines []string
- width int
-}
-
-func NewVariableHeightItem(id string, lines []string) *VariableHeightItem {
- return &VariableHeightItem{
- id: id,
- lines: lines,
- width: 80,
- }
-}
-
-func (v *VariableHeightItem) ID() string {
- return v.id
-}
-
-func (v *VariableHeightItem) Height(width int) int {
- return len(v.lines)
-}
-
-func (v *VariableHeightItem) Draw(scr uv.Screen, area uv.Rectangle) {
- content := ""
- for i, line := range v.lines {
- if i > 0 {
- content += "\n"
- }
- content += line
- }
- styled := uv.NewStyledString(content)
- styled.Draw(scr, area)
-}
-
-// Example demonstrates variable height items.
-func Example_variableHeights() {
- items := []list.Item{
- NewVariableHeightItem("1", []string{"Short item"}),
- NewVariableHeightItem("2", []string{
- "This is a taller item",
- "that spans multiple lines",
- "to demonstrate variable heights",
- }),
- NewVariableHeightItem("3", []string{"Another short item"}),
- NewVariableHeightItem("4", []string{
- "A medium height item",
- "with two lines",
- }),
- }
-
- l := list.New(items...)
- l.SetSize(80, 15)
-
- screen := uv.NewScreenBuffer(80, 15)
- area := uv.Rect(0, 0, 80, 15)
- l.Draw(&screen, area)
-
- output := screen.Render()
- fmt.Println(output)
-}
-
-// Example demonstrates markdown items in a list.
-func Example_markdown() {
- // Create markdown items
- items := []list.Item{
- list.NewMarkdownItem("# Welcome\n\nThis is a **markdown** item."),
- list.NewMarkdownItem("## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"),
- list.NewMarkdownItem("### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"),
- }
-
- // Create list
- l := list.New(items...)
- l.SetSize(80, 20)
-
- screen := uv.NewScreenBuffer(80, 20)
- area := uv.Rect(0, 0, 80, 20)
- l.Draw(&screen, area)
-
- output := screen.Render()
- fmt.Println(output)
-}
@@ -1,578 +0,0 @@
-package list
-
-import (
- "image"
- "strings"
-
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/glamour/v2"
- "github.com/charmbracelet/glamour/v2/ansi"
- uv "github.com/charmbracelet/ultraviolet"
- "github.com/charmbracelet/ultraviolet/screen"
-)
-
-// toUVStyle converts a lipgloss.Style to a uv.Style, stripping multiline attributes.
-func toUVStyle(lgStyle lipgloss.Style) uv.Style {
- var uvStyle uv.Style
-
- // Colors are already color.Color
- uvStyle.Fg = lgStyle.GetForeground()
- uvStyle.Bg = lgStyle.GetBackground()
-
- // Build attributes using bitwise OR
- var attrs uint8
-
- if lgStyle.GetBold() {
- attrs |= uv.AttrBold
- }
-
- if lgStyle.GetItalic() {
- attrs |= uv.AttrItalic
- }
-
- if lgStyle.GetUnderline() {
- uvStyle.Underline = uv.UnderlineSingle
- }
-
- if lgStyle.GetStrikethrough() {
- attrs |= uv.AttrStrikethrough
- }
-
- if lgStyle.GetFaint() {
- attrs |= uv.AttrFaint
- }
-
- if lgStyle.GetBlink() {
- attrs |= uv.AttrBlink
- }
-
- if lgStyle.GetReverse() {
- attrs |= uv.AttrReverse
- }
-
- uvStyle.Attrs = attrs
-
- return uvStyle
-}
-
-// Item represents a list item that can draw itself to a UV buffer.
-// Items implement the uv.Drawable interface.
-type Item interface {
- uv.Drawable
-
- // Height returns the item's height in lines for the given width.
- // This allows items to calculate height based on text wrapping and available space.
- Height(width int) int
-}
-
-// Focusable is an optional interface for items that support focus.
-// When implemented, items can change appearance when focused (borders, colors, etc).
-type Focusable interface {
- Focus()
- Blur()
- IsFocused() bool
-}
-
-// Highlightable is an optional interface for items that support highlighting.
-// When implemented, items can highlight specific regions (e.g. for search matches).
-type Highlightable interface {
- // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol).
- // Use -1 for all values to clear highlighting.
- SetHighlight(startLine, startCol, endLine, endCol int)
-
- // GetHighlight returns the current highlight region.
- GetHighlight() (startLine, startCol, endLine, endCol int)
-}
-
-// BaseFocusable provides common focus state and styling for items.
-// Embed this type to add focus behavior to any item.
-type BaseFocusable struct {
- focused bool
- focusStyle *lipgloss.Style
- blurStyle *lipgloss.Style
-}
-
-// Focus implements Focusable interface.
-func (b *BaseFocusable) Focus() {
- b.focused = true
-}
-
-// Blur implements Focusable interface.
-func (b *BaseFocusable) Blur() {
- b.focused = false
-}
-
-// IsFocused implements Focusable interface.
-func (b *BaseFocusable) IsFocused() bool {
- return b.focused
-}
-
-// HasFocusStyles returns true if both focus and blur styles are configured.
-func (b *BaseFocusable) HasFocusStyles() bool {
- return b.focusStyle != nil && b.blurStyle != nil
-}
-
-// CurrentStyle returns the current style based on focus state.
-// Returns nil if no styles are configured, or if the current state's style is nil.
-func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
- if b.focused {
- return b.focusStyle
- }
- return b.blurStyle
-}
-
-// SetFocusStyles sets the focus and blur styles.
-func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
- b.focusStyle = focusStyle
- b.blurStyle = blurStyle
-}
-
-// BaseHighlightable provides common highlight state for items.
-// Embed this type to add highlight behavior to any item.
-type BaseHighlightable struct {
- highlightStartLine int
- highlightStartCol int
- highlightEndLine int
- highlightEndCol int
- highlightStyle CellStyler
-}
-
-// SetHighlight implements Highlightable interface.
-func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) {
- b.highlightStartLine = startLine
- b.highlightStartCol = startCol
- b.highlightEndLine = endLine
- b.highlightEndCol = endCol
-}
-
-// GetHighlight implements Highlightable interface.
-func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) {
- return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol
-}
-
-// HasHighlight returns true if a highlight region is set.
-func (b *BaseHighlightable) HasHighlight() bool {
- return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 ||
- b.highlightEndLine >= 0 || b.highlightEndCol >= 0
-}
-
-// SetHighlightStyle sets the style function used for highlighting.
-func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
- b.highlightStyle = style
-}
-
-// GetHighlightStyle returns the current highlight style function.
-func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
- return b.highlightStyle
-}
-
-// InitHighlight initializes the highlight fields with default values.
-func (b *BaseHighlightable) InitHighlight() {
- b.highlightStartLine = -1
- b.highlightStartCol = -1
- b.highlightEndLine = -1
- b.highlightEndCol = -1
- b.highlightStyle = LipglossStyleToCellStyler(lipgloss.NewStyle().Reverse(true))
-}
-
-// ApplyHighlight applies highlighting to a screen buffer.
-// This should be called after drawing content to the buffer.
-func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
- if b.highlightStartLine < 0 {
- return
- }
-
- var (
- topMargin, topBorder, topPadding int
- rightMargin, rightBorder, rightPadding int
- bottomMargin, bottomBorder, bottomPadding int
- leftMargin, leftBorder, leftPadding int
- )
- if style != nil {
- topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
- topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
- style.GetBorderRightSize(),
- style.GetBorderBottomSize(),
- style.GetBorderLeftSize()
- topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
- }
-
- // Calculate content area offsets
- contentArea := image.Rectangle{
- Min: image.Point{
- X: leftMargin + leftBorder + leftPadding,
- Y: topMargin + topBorder + topPadding,
- },
- Max: image.Point{
- X: width - (rightMargin + rightBorder + rightPadding),
- Y: height - (bottomMargin + bottomBorder + bottomPadding),
- },
- }
-
- for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
- if y >= buf.Height() {
- break
- }
-
- line := buf.Line(y)
-
- // Determine column range for this line
- startCol := 0
- if y == b.highlightStartLine {
- startCol = min(b.highlightStartCol, len(line))
- }
-
- endCol := len(line)
- if y == b.highlightEndLine {
- endCol = min(b.highlightEndCol, len(line))
- }
-
- // Track last non-empty position as we go
- lastContentX := -1
-
- // Single pass: check content and track last non-empty position
- for x := startCol; x < endCol; x++ {
- cell := line.At(x)
- if cell == nil {
- continue
- }
-
- // Update last content position if non-empty
- if cell.Content != "" && cell.Content != " " {
- lastContentX = x
- }
- }
-
- // Only apply highlight up to last content position
- highlightEnd := endCol
- if lastContentX >= 0 {
- highlightEnd = lastContentX + 1
- } else if lastContentX == -1 {
- highlightEnd = startCol // No content on this line
- }
-
- // Apply highlight style only to cells with content
- for x := startCol; x < highlightEnd; x++ {
- if !image.Pt(x, y).In(contentArea) {
- continue
- }
- cell := line.At(x)
- cell.Style = b.highlightStyle(cell.Style)
- }
- }
-}
-
-// StringItem is a simple string-based item with optional text wrapping.
-// It caches rendered content by width for efficient repeated rendering.
-// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
-// StringItem implements Highlightable for text selection/search highlighting.
-type StringItem struct {
- BaseFocusable
- BaseHighlightable
- content string // Raw content string (may contain ANSI styles)
- wrap bool // Whether to wrap text
-
- // Cache for rendered content at specific widths
- // Key: width, Value: string
- cache map[int]string
-}
-
-// CellStyler is a function that applies styles to UV cells.
-type CellStyler = func(s uv.Style) uv.Style
-
-var noColor = lipgloss.NoColor{}
-
-// LipglossStyleToCellStyler converts a Lip Gloss style to a CellStyler function.
-func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler {
- uvStyle := toUVStyle(lgStyle)
- return func(s uv.Style) uv.Style {
- if uvStyle.Fg != nil && lgStyle.GetForeground() != noColor {
- s.Fg = uvStyle.Fg
- }
- if uvStyle.Bg != nil && lgStyle.GetBackground() != noColor {
- s.Bg = uvStyle.Bg
- }
- s.Attrs |= uvStyle.Attrs
- if uvStyle.Underline != 0 {
- s.Underline = uvStyle.Underline
- }
- return s
- }
-}
-
-// NewStringItem creates a new string item with the given ID and content.
-func NewStringItem(content string) *StringItem {
- s := &StringItem{
- content: content,
- wrap: false,
- cache: make(map[int]string),
- }
- s.InitHighlight()
- return s
-}
-
-// NewWrappingStringItem creates a new string item that wraps text to fit width.
-func NewWrappingStringItem(content string) *StringItem {
- s := &StringItem{
- content: content,
- wrap: true,
- cache: make(map[int]string),
- }
- s.InitHighlight()
- return s
-}
-
-// WithFocusStyles sets the focus and blur styles for the string item.
-// If both styles are non-nil, the item will implement Focusable.
-func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem {
- s.SetFocusStyles(focusStyle, blurStyle)
- return s
-}
-
-// Height implements Item.
-func (s *StringItem) Height(width int) int {
- // Calculate content width if we have styles
- contentWidth := width
- if style := s.CurrentStyle(); style != nil {
- hFrameSize := style.GetHorizontalFrameSize()
- if hFrameSize > 0 {
- contentWidth -= hFrameSize
- }
- }
-
- var lines int
- if !s.wrap {
- // No wrapping - height is just the number of newlines + 1
- lines = strings.Count(s.content, "\n") + 1
- } else {
- // Use lipgloss.Wrap to wrap the content and count lines
- // This preserves ANSI styles and is much faster than rendering to a buffer
- wrapped := lipgloss.Wrap(s.content, contentWidth, "")
- lines = strings.Count(wrapped, "\n") + 1
- }
-
- // Add vertical frame size if we have styles
- if style := s.CurrentStyle(); style != nil {
- lines += style.GetVerticalFrameSize()
- }
-
- return lines
-}
-
-// Draw implements Item and uv.Drawable.
-func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
- width := area.Dx()
- height := area.Dy()
-
- // Check cache first
- content, ok := s.cache[width]
- if !ok {
- // Not cached - create and cache
- content = s.content
- if s.wrap {
- // Wrap content using lipgloss
- content = lipgloss.Wrap(s.content, width, "")
- }
- s.cache[width] = content
- }
-
- // Apply focus/blur styling if configured
- style := s.CurrentStyle()
- if style != nil {
- content = style.Width(width).Render(content)
- }
-
- // Create temp buffer to draw content with highlighting
- tempBuf := uv.NewScreenBuffer(width, height)
-
- // Draw content to temp buffer first
- styled := uv.NewStyledString(content)
- styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
-
- // Apply highlighting if active
- s.ApplyHighlight(&tempBuf, width, height, style)
-
- // Copy temp buffer to actual screen at the target area
- tempBuf.Draw(scr, area)
-}
-
-// SetHighlight implements Highlightable and extends BaseHighlightable.
-// Clears the cache when highlight changes.
-func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) {
- s.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
- // Clear cache when highlight changes
- s.cache = make(map[int]string)
-}
-
-// MarkdownItem renders markdown content using Glamour.
-// It caches all rendered content by width for efficient repeated rendering.
-// The wrap width is capped at 120 cells by default to ensure readable line lengths.
-// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
-// MarkdownItem implements Highlightable for text selection/search highlighting.
-type MarkdownItem struct {
- BaseFocusable
- BaseHighlightable
- markdown string // Raw markdown content
- styleConfig *ansi.StyleConfig // Optional style configuration
- maxWidth int // Maximum wrap width (default 120)
-
- // Cache for rendered content at specific widths
- // Key: width (capped to maxWidth), Value: rendered markdown string
- cache map[int]string
-}
-
-// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering.
-const DefaultMarkdownMaxWidth = 120
-
-// NewMarkdownItem creates a new markdown item with the given ID and markdown content.
-// If focusStyle and blurStyle are both non-nil, the item will implement Focusable.
-func NewMarkdownItem(markdown string) *MarkdownItem {
- m := &MarkdownItem{
- markdown: markdown,
- maxWidth: DefaultMarkdownMaxWidth,
- cache: make(map[int]string),
- }
- m.InitHighlight()
- return m
-}
-
-// WithStyleConfig sets a custom Glamour style configuration for the markdown item.
-func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem {
- m.styleConfig = &styleConfig
- return m
-}
-
-// WithMaxWidth sets the maximum wrap width for markdown rendering.
-func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem {
- m.maxWidth = maxWidth
- return m
-}
-
-// WithFocusStyles sets the focus and blur styles for the markdown item.
-// If both styles are non-nil, the item will implement Focusable.
-func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem {
- m.SetFocusStyles(focusStyle, blurStyle)
- return m
-}
-
-// Height implements Item.
-func (m *MarkdownItem) Height(width int) int {
- // Render the markdown to get its height
- rendered := m.renderMarkdown(width)
-
- // Apply focus/blur styling if configured to get accurate height
- if style := m.CurrentStyle(); style != nil {
- rendered = style.Render(rendered)
- }
-
- return strings.Count(rendered, "\n") + 1
-}
-
-// Draw implements Item and uv.Drawable.
-func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) {
- width := area.Dx()
- height := area.Dy()
- rendered := m.renderMarkdown(width)
-
- // Apply focus/blur styling if configured
- style := m.CurrentStyle()
- if style != nil {
- rendered = style.Render(rendered)
- }
-
- // Create temp buffer to draw content with highlighting
- tempBuf := uv.NewScreenBuffer(width, height)
-
- // Draw the rendered markdown to temp buffer
- styled := uv.NewStyledString(rendered)
- styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
-
- // Apply highlighting if active
- m.ApplyHighlight(&tempBuf, width, height, style)
-
- // Copy temp buffer to actual screen at the target area
- tempBuf.Draw(scr, area)
-}
-
-// renderMarkdown renders the markdown content at the given width, using cache if available.
-// Width is always capped to maxWidth to ensure readable line lengths.
-func (m *MarkdownItem) renderMarkdown(width int) string {
- // Cap width to maxWidth
- cappedWidth := min(width, m.maxWidth)
-
- // Check cache first (always cache all rendered markdown)
- if cached, ok := m.cache[cappedWidth]; ok {
- return cached
- }
-
- // Not cached - render now
- opts := []glamour.TermRendererOption{
- glamour.WithWordWrap(cappedWidth),
- }
-
- // Add style config if provided
- if m.styleConfig != nil {
- opts = append(opts, glamour.WithStyles(*m.styleConfig))
- }
-
- renderer, err := glamour.NewTermRenderer(opts...)
- if err != nil {
- // Fallback to plain text on error
- return m.markdown
- }
-
- rendered, err := renderer.Render(m.markdown)
- if err != nil {
- // Fallback to plain text on error
- return m.markdown
- }
-
- // Trim trailing whitespace
- rendered = strings.TrimRight(rendered, "\n\r ")
-
- // Always cache
- m.cache[cappedWidth] = rendered
-
- return rendered
-}
-
-// SetHighlight implements Highlightable and extends BaseHighlightable.
-// Clears the cache when highlight changes.
-func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) {
- m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
- // Clear cache when highlight changes
- m.cache = make(map[int]string)
-}
-
-// Gap is a 1-line spacer item used to add gaps between items.
-var Gap = NewSpacerItem(1)
-
-// SpacerItem is an empty item that takes up vertical space.
-// Useful for adding gaps between items in a list.
-type SpacerItem struct {
- height int
-}
-
-var _ Item = (*SpacerItem)(nil)
-
-// NewSpacerItem creates a new spacer item with the given ID and height in lines.
-func NewSpacerItem(height int) *SpacerItem {
- return &SpacerItem{
- height: height,
- }
-}
-
-// Height implements Item.
-func (s *SpacerItem) Height(width int) int {
- return s.height
-}
-
-// Draw implements Item.
-// Spacer items don't draw anything, they just take up space.
-func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) {
- // Ensure the area is filled with spaces to clear any existing content
- spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height))
- if spacerArea.Overlaps(area) {
- screen.ClearArea(scr, spacerArea)
- }
-}
@@ -1,593 +0,0 @@
-package list
-
-import (
- "strings"
- "testing"
-
- "github.com/charmbracelet/glamour/v2/ansi"
- uv "github.com/charmbracelet/ultraviolet"
-)
-
-func TestRenderHelper(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- NewStringItem("Item 3"),
- }
-
- l := New(items...)
- l.SetSize(80, 10)
-
- // Render to string
- output := l.Render()
-
- if len(output) == 0 {
- t.Error("expected non-empty output from Render()")
- }
-
- // Check that output contains the items
- if !strings.Contains(output, "Item 1") {
- t.Error("expected output to contain 'Item 1'")
- }
- if !strings.Contains(output, "Item 2") {
- t.Error("expected output to contain 'Item 2'")
- }
- if !strings.Contains(output, "Item 3") {
- t.Error("expected output to contain 'Item 3'")
- }
-}
-
-func TestRenderWithScrolling(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- NewStringItem("Item 3"),
- NewStringItem("Item 4"),
- NewStringItem("Item 5"),
- }
-
- l := New(items...)
- l.SetSize(80, 2) // Small viewport
-
- // Initial render should show first 2 items
- output := l.Render()
- if !strings.Contains(output, "Item 1") {
- t.Error("expected output to contain 'Item 1'")
- }
- if !strings.Contains(output, "Item 2") {
- t.Error("expected output to contain 'Item 2'")
- }
- if strings.Contains(output, "Item 3") {
- t.Error("expected output to NOT contain 'Item 3' in initial view")
- }
-
- // Scroll down and render
- l.ScrollBy(2)
- output = l.Render()
-
- // Now should show items 3 and 4
- if strings.Contains(output, "Item 1") {
- t.Error("expected output to NOT contain 'Item 1' after scrolling")
- }
- if !strings.Contains(output, "Item 3") {
- t.Error("expected output to contain 'Item 3' after scrolling")
- }
- if !strings.Contains(output, "Item 4") {
- t.Error("expected output to contain 'Item 4' after scrolling")
- }
-}
-
-func TestRenderEmptyList(t *testing.T) {
- l := New()
- l.SetSize(80, 10)
-
- output := l.Render()
- if output != "" {
- t.Errorf("expected empty output for empty list, got: %q", output)
- }
-}
-
-func TestRenderVsDrawConsistency(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- }
-
- l := New(items...)
- l.SetSize(80, 10)
-
- // Render using Render() method
- renderOutput := l.Render()
-
- // Render using Draw() method
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
- l.Draw(&screen, area)
- drawOutput := screen.Render()
-
- // Trim any trailing whitespace for comparison
- renderOutput = strings.TrimRight(renderOutput, "\n")
- drawOutput = strings.TrimRight(drawOutput, "\n")
-
- // Both methods should produce the same output
- if renderOutput != drawOutput {
- t.Errorf("Render() and Draw() produced different outputs:\nRender():\n%q\n\nDraw():\n%q",
- renderOutput, drawOutput)
- }
-}
-
-func BenchmarkRender(b *testing.B) {
- items := make([]Item, 100)
- for i := range items {
- items[i] = NewStringItem("Item content here")
- }
-
- l := New(items...)
- l.SetSize(80, 24)
- l.Render() // Prime the buffer
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- _ = l.Render()
- }
-}
-
-func BenchmarkRenderWithScrolling(b *testing.B) {
- items := make([]Item, 1000)
- for i := range items {
- items[i] = NewStringItem("Item content here")
- }
-
- l := New(items...)
- l.SetSize(80, 24)
- l.Render() // Prime the buffer
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- l.ScrollBy(1)
- _ = l.Render()
- }
-}
-
-func TestStringItemCache(t *testing.T) {
- item := NewStringItem("Test content")
-
- // First draw at width 80 should populate cache
- screen1 := uv.NewScreenBuffer(80, 5)
- area1 := uv.Rect(0, 0, 80, 5)
- item.Draw(&screen1, area1)
-
- if len(item.cache) != 1 {
- t.Errorf("expected cache to have 1 entry after first draw, got %d", len(item.cache))
- }
- if _, ok := item.cache[80]; !ok {
- t.Error("expected cache to have entry for width 80")
- }
-
- // Second draw at same width should reuse cache
- screen2 := uv.NewScreenBuffer(80, 5)
- area2 := uv.Rect(0, 0, 80, 5)
- item.Draw(&screen2, area2)
-
- if len(item.cache) != 1 {
- t.Errorf("expected cache to still have 1 entry after second draw, got %d", len(item.cache))
- }
-
- // Draw at different width should add to cache
- screen3 := uv.NewScreenBuffer(40, 5)
- area3 := uv.Rect(0, 0, 40, 5)
- item.Draw(&screen3, area3)
-
- if len(item.cache) != 2 {
- t.Errorf("expected cache to have 2 entries after draw at different width, got %d", len(item.cache))
- }
- if _, ok := item.cache[40]; !ok {
- t.Error("expected cache to have entry for width 40")
- }
-}
-
-func TestWrappingItemHeight(t *testing.T) {
- // Short text that fits in one line
- item1 := NewWrappingStringItem("Short")
- if h := item1.Height(80); h != 1 {
- t.Errorf("expected height 1 for short text, got %d", h)
- }
-
- // Long text that wraps
- longText := "This is a very long line that will definitely wrap when constrained to a narrow width"
- item2 := NewWrappingStringItem(longText)
-
- // At width 80, should be fewer lines than width 20
- height80 := item2.Height(80)
- height20 := item2.Height(20)
-
- if height20 <= height80 {
- t.Errorf("expected more lines at narrow width (20: %d lines) than wide width (80: %d lines)",
- height20, height80)
- }
-
- // Non-wrapping version should always be 1 line
- item3 := NewStringItem(longText)
- if h := item3.Height(20); h != 1 {
- t.Errorf("expected height 1 for non-wrapping item, got %d", h)
- }
-}
-
-func TestMarkdownItemBasic(t *testing.T) {
- markdown := "# Hello\n\nThis is a **test**."
- item := NewMarkdownItem(markdown)
-
- // Test that height is calculated
- height := item.Height(80)
- if height < 1 {
- t.Errorf("expected height >= 1, got %d", height)
- }
-
- // Test drawing
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
- item.Draw(&screen, area)
-
- // Should not panic and should render something
- rendered := screen.Render()
- if len(rendered) == 0 {
- t.Error("expected non-empty rendered output")
- }
-}
-
-func TestMarkdownItemCache(t *testing.T) {
- markdown := "# Test\n\nSome content."
- item := NewMarkdownItem(markdown)
-
- // First render at width 80 should populate cache
- height1 := item.Height(80)
- if len(item.cache) != 1 {
- t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache))
- }
-
- // Second render at same width should reuse cache
- height2 := item.Height(80)
- if height1 != height2 {
- t.Errorf("expected consistent height, got %d then %d", height1, height2)
- }
- if len(item.cache) != 1 {
- t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache))
- }
-
- // Render at different width should add to cache
- _ = item.Height(40)
- if len(item.cache) != 2 {
- t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache))
- }
-}
-
-func TestMarkdownItemMaxCacheWidth(t *testing.T) {
- markdown := "# Test\n\nSome content."
- item := NewMarkdownItem(markdown).WithMaxWidth(50)
-
- // Render at width 40 (below limit) - should cache at width 40
- _ = item.Height(40)
- if len(item.cache) != 1 {
- t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache))
- }
-
- // Render at width 80 (above limit) - should cap to 50 and cache
- _ = item.Height(80)
- // Cache should have width 50 entry (capped from 80)
- if len(item.cache) != 2 {
- t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache))
- }
- if _, ok := item.cache[50]; !ok {
- t.Error("expected cache to have entry for width 50 (capped from 80)")
- }
-
- // Render at width 100 (also above limit) - should reuse cached width 50
- _ = item.Height(100)
- if len(item.cache) != 2 {
- t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache))
- }
-}
-
-func TestMarkdownItemWithStyleConfig(t *testing.T) {
- markdown := "# Styled\n\nContent with **bold** text."
-
- // Create a custom style config
- styleConfig := ansi.StyleConfig{
- Document: ansi.StyleBlock{
- Margin: uintPtr(0),
- },
- }
-
- item := NewMarkdownItem(markdown).WithStyleConfig(styleConfig)
-
- // Render should use the custom style
- height := item.Height(80)
- if height < 1 {
- t.Errorf("expected height >= 1, got %d", height)
- }
-
- // Draw should work without panic
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
- item.Draw(&screen, area)
-
- rendered := screen.Render()
- if len(rendered) == 0 {
- t.Error("expected non-empty rendered output with custom style")
- }
-}
-
-func TestMarkdownItemInList(t *testing.T) {
- items := []Item{
- NewMarkdownItem("# First\n\nMarkdown item."),
- NewMarkdownItem("# Second\n\nAnother item."),
- NewStringItem("Regular string item"),
- }
-
- l := New(items...)
- l.SetSize(80, 20)
-
- // Should render without error
- output := l.Render()
- if len(output) == 0 {
- t.Error("expected non-empty output from list with markdown items")
- }
-
- // Should contain content from markdown items
- if !strings.Contains(output, "First") {
- t.Error("expected output to contain 'First'")
- }
- if !strings.Contains(output, "Second") {
- t.Error("expected output to contain 'Second'")
- }
- if !strings.Contains(output, "Regular string item") {
- t.Error("expected output to contain 'Regular string item'")
- }
-}
-
-func TestMarkdownItemHeightWithWidth(t *testing.T) {
- // Test that widths are capped to maxWidth
- markdown := "This is a paragraph with some text."
-
- item := NewMarkdownItem(markdown).WithMaxWidth(50)
-
- // At width 30 (below limit), should cache and render at width 30
- height30 := item.Height(30)
- if height30 < 1 {
- t.Errorf("expected height >= 1, got %d", height30)
- }
-
- // At width 100 (above maxWidth), should cap to 50 and cache
- height100 := item.Height(100)
- if height100 < 1 {
- t.Errorf("expected height >= 1, got %d", height100)
- }
-
- // Both should be cached (width 30 and capped width 50)
- if len(item.cache) != 2 {
- t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache))
- }
- if _, ok := item.cache[30]; !ok {
- t.Error("expected cache to have entry for width 30")
- }
- if _, ok := item.cache[50]; !ok {
- t.Error("expected cache to have entry for width 50 (capped from 100)")
- }
-}
-
-func BenchmarkMarkdownItemRender(b *testing.B) {
- markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
- item := NewMarkdownItem(markdown)
-
- // Prime the cache
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
- item.Draw(&screen, area)
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
- item.Draw(&screen, area)
- }
-}
-
-func BenchmarkMarkdownItemUncached(b *testing.B) {
- markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- item := NewMarkdownItem(markdown)
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
- item.Draw(&screen, area)
- }
-}
-
-func TestSpacerItem(t *testing.T) {
- spacer := NewSpacerItem(3)
-
- // Check height
- if h := spacer.Height(80); h != 3 {
- t.Errorf("expected height 3, got %d", h)
- }
-
- // Height should be constant regardless of width
- if h := spacer.Height(20); h != 3 {
- t.Errorf("expected height 3 for width 20, got %d", h)
- }
-
- // Draw should not produce any visible content
- screen := uv.NewScreenBuffer(20, 3)
- area := uv.Rect(0, 0, 20, 3)
- spacer.Draw(&screen, area)
-
- output := screen.Render()
- // Should be empty (just spaces)
- for _, line := range strings.Split(output, "\n") {
- trimmed := strings.TrimSpace(line)
- if trimmed != "" {
- t.Errorf("expected empty spacer output, got: %q", line)
- }
- }
-}
-
-func TestSpacerItemInList(t *testing.T) {
- // Create a list with items separated by spacers
- items := []Item{
- NewStringItem("Item 1"),
- NewSpacerItem(1),
- NewStringItem("Item 2"),
- NewSpacerItem(2),
- NewStringItem("Item 3"),
- }
-
- l := New(items...)
- l.SetSize(20, 10)
-
- output := l.Render()
-
- // Should contain all three items
- if !strings.Contains(output, "Item 1") {
- t.Error("expected output to contain 'Item 1'")
- }
- if !strings.Contains(output, "Item 2") {
- t.Error("expected output to contain 'Item 2'")
- }
- if !strings.Contains(output, "Item 3") {
- t.Error("expected output to contain 'Item 3'")
- }
-
- // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6
- expectedHeight := 6
- if l.TotalHeight() != expectedHeight {
- t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight())
- }
-}
-
-func TestSpacerItemNavigation(t *testing.T) {
- // Spacers should not be selectable (they're not focusable)
- items := []Item{
- NewStringItem("Item 1"),
- NewSpacerItem(1),
- NewStringItem("Item 2"),
- }
-
- l := New(items...)
- l.SetSize(20, 10)
-
- // Select first item
- l.SetSelected(0)
- if l.SelectedIndex() != 0 {
- t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
- }
-
- // Can select the spacer (it's a valid item, just not focusable)
- l.SetSelected(1)
- if l.SelectedIndex() != 1 {
- t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
- }
-
- // Can select item after spacer
- l.SetSelected(2)
- if l.SelectedIndex() != 2 {
- t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
- }
-}
-
-// Helper function to create a pointer to uint
-func uintPtr(v uint) *uint {
- return &v
-}
-
-func TestListDoesNotEatLastLine(t *testing.T) {
- // Create items that exactly fill the viewport
- items := []Item{
- NewStringItem("Line 1"),
- NewStringItem("Line 2"),
- NewStringItem("Line 3"),
- NewStringItem("Line 4"),
- NewStringItem("Line 5"),
- }
-
- // Create list with height exactly matching content (5 lines, no gaps)
- l := New(items...)
- l.SetSize(20, 5)
-
- // Render the list
- output := l.Render()
-
- // Count actual lines in output
- lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
- actualLineCount := 0
- for _, line := range lines {
- if strings.TrimSpace(line) != "" {
- actualLineCount++
- }
- }
-
- // All 5 items should be visible
- if !strings.Contains(output, "Line 1") {
- t.Error("expected output to contain 'Line 1'")
- }
- if !strings.Contains(output, "Line 2") {
- t.Error("expected output to contain 'Line 2'")
- }
- if !strings.Contains(output, "Line 3") {
- t.Error("expected output to contain 'Line 3'")
- }
- if !strings.Contains(output, "Line 4") {
- t.Error("expected output to contain 'Line 4'")
- }
- if !strings.Contains(output, "Line 5") {
- t.Error("expected output to contain 'Line 5'")
- }
-
- if actualLineCount != 5 {
- t.Errorf("expected 5 lines with content, got %d", actualLineCount)
- }
-}
-
-func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
- // Create more items than viewport height
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- NewStringItem("Item 3"),
- NewStringItem("Item 4"),
- NewStringItem("Item 5"),
- NewStringItem("Item 6"),
- NewStringItem("Item 7"),
- }
-
- // Viewport shows 3 items at a time
- l := New(items...)
- l.SetSize(20, 3)
-
- // Need to render first to build the buffer and calculate total height
- _ = l.Render()
-
- // Now scroll to bottom
- l.ScrollToBottom()
-
- output := l.Render()
-
- t.Logf("Output:\n%s", output)
- t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
-
- // Should show last 3 items: 5, 6, 7
- if !strings.Contains(output, "Item 5") {
- t.Error("expected output to contain 'Item 5'")
- }
- if !strings.Contains(output, "Item 6") {
- t.Error("expected output to contain 'Item 6'")
- }
- if !strings.Contains(output, "Item 7") {
- t.Error("expected output to contain 'Item 7'")
- }
-
- // Should not show earlier items
- if strings.Contains(output, "Item 1") {
- t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom")
- }
-}
@@ -1,1007 +0,0 @@
-package list
-
-import (
- "strings"
-
- uv "github.com/charmbracelet/ultraviolet"
- "github.com/charmbracelet/ultraviolet/screen"
-)
-
-// LazyList is a virtual scrolling list that only renders visible items.
-// It uses height estimates to avoid expensive renders during initial layout.
-type LazyList struct {
- // Configuration
- width, height int
-
- // Data
- items []Item
-
- // Focus & Selection
- focused bool
- selectedIdx int // Currently selected item index (-1 if none)
-
- // Item positioning - tracks measured and estimated positions
- itemHeights []itemHeight
- totalHeight int // Sum of all item heights (measured or estimated)
-
- // Viewport state
- offset int // Scroll offset in lines from top
-
- // Rendered items cache - only visible items are rendered
- renderedCache map[int]*renderedItemCache
-
- // Virtual scrolling configuration
- defaultEstimate int // Default height estimate for unmeasured items
- overscan int // Number of items to render outside viewport for smooth scrolling
-
- // Dirty tracking
- needsLayout bool
- dirtyItems map[int]bool
- dirtyViewport bool // True if we need to re-render viewport
-
- // Mouse state
- mouseDown bool
- mouseDownItem int
- mouseDownX int
- mouseDownY int
- mouseDragItem int
- mouseDragX int
- mouseDragY int
-}
-
-// itemHeight tracks the height of an item - either measured or estimated.
-type itemHeight struct {
- height int
- measured bool // true if height is actual measurement, false if estimate
-}
-
-// renderedItemCache stores a rendered item's buffer.
-type renderedItemCache struct {
- buffer *uv.ScreenBuffer
- height int // Actual measured height after rendering
-}
-
-// NewLazyList creates a new lazy-rendering list.
-func NewLazyList(items ...Item) *LazyList {
- l := &LazyList{
- items: items,
- itemHeights: make([]itemHeight, len(items)),
- renderedCache: make(map[int]*renderedItemCache),
- dirtyItems: make(map[int]bool),
- selectedIdx: -1,
- mouseDownItem: -1,
- mouseDragItem: -1,
- defaultEstimate: 10, // Conservative estimate: 5 lines per item
- overscan: 5, // Render 3 items above/below viewport
- needsLayout: true,
- dirtyViewport: true,
- }
-
- // Initialize all items with estimated heights
- for i := range l.items {
- l.itemHeights[i] = itemHeight{
- height: l.defaultEstimate,
- measured: false,
- }
- }
- l.calculateTotalHeight()
-
- return l
-}
-
-// calculateTotalHeight sums all item heights (measured or estimated).
-func (l *LazyList) calculateTotalHeight() {
- l.totalHeight = 0
- for _, h := range l.itemHeights {
- l.totalHeight += h.height
- }
-}
-
-// getItemPosition returns the Y position where an item starts.
-func (l *LazyList) getItemPosition(idx int) int {
- pos := 0
- for i := 0; i < idx && i < len(l.itemHeights); i++ {
- pos += l.itemHeights[i].height
- }
- return pos
-}
-
-// findVisibleItems returns the range of items that are visible or near the viewport.
-func (l *LazyList) findVisibleItems() (firstIdx, lastIdx int) {
- if len(l.items) == 0 {
- return 0, 0
- }
-
- viewportStart := l.offset
- viewportEnd := l.offset + l.height
-
- // Find first visible item
- firstIdx = -1
- pos := 0
- for i := 0; i < len(l.items); i++ {
- itemEnd := pos + l.itemHeights[i].height
- if itemEnd > viewportStart {
- firstIdx = i
- break
- }
- pos = itemEnd
- }
-
- // Apply overscan above
- firstIdx = max(0, firstIdx-l.overscan)
-
- // Find last visible item
- lastIdx = firstIdx
- pos = l.getItemPosition(firstIdx)
- for i := firstIdx; i < len(l.items); i++ {
- if pos >= viewportEnd {
- break
- }
- pos += l.itemHeights[i].height
- lastIdx = i
- }
-
- // Apply overscan below
- lastIdx = min(len(l.items)-1, lastIdx+l.overscan)
-
- return firstIdx, lastIdx
-}
-
-// renderItem renders a single item and caches it.
-// Returns the actual measured height.
-func (l *LazyList) renderItem(idx int) int {
- if idx < 0 || idx >= len(l.items) {
- return 0
- }
-
- item := l.items[idx]
-
- // Measure actual height
- actualHeight := item.Height(l.width)
-
- // Create buffer and render
- buf := uv.NewScreenBuffer(l.width, actualHeight)
- area := uv.Rect(0, 0, l.width, actualHeight)
- item.Draw(&buf, area)
-
- // Cache rendered item
- l.renderedCache[idx] = &renderedItemCache{
- buffer: &buf,
- height: actualHeight,
- }
-
- // Update height if it was estimated or changed
- if !l.itemHeights[idx].measured || l.itemHeights[idx].height != actualHeight {
- oldHeight := l.itemHeights[idx].height
- l.itemHeights[idx] = itemHeight{
- height: actualHeight,
- measured: true,
- }
-
- // Adjust total height
- l.totalHeight += actualHeight - oldHeight
- }
-
- return actualHeight
-}
-
-// Draw implements uv.Drawable.
-func (l *LazyList) Draw(scr uv.Screen, area uv.Rectangle) {
- if area.Dx() <= 0 || area.Dy() <= 0 {
- return
- }
-
- widthChanged := l.width != area.Dx()
- heightChanged := l.height != area.Dy()
-
- l.width = area.Dx()
- l.height = area.Dy()
-
- // Width changes invalidate all cached renders
- if widthChanged {
- l.renderedCache = make(map[int]*renderedItemCache)
- // Mark all heights as needing remeasurement
- for i := range l.itemHeights {
- l.itemHeights[i].measured = false
- l.itemHeights[i].height = l.defaultEstimate
- }
- l.calculateTotalHeight()
- l.needsLayout = true
- l.dirtyViewport = true
- }
-
- if heightChanged {
- l.clampOffset()
- l.dirtyViewport = true
- }
-
- if len(l.items) == 0 {
- screen.ClearArea(scr, area)
- return
- }
-
- // Find visible items based on current estimates
- firstIdx, lastIdx := l.findVisibleItems()
-
- // Track the first visible item's position to maintain stability
- // Only stabilize if we're not at the top boundary
- stabilizeIdx := -1
- stabilizeY := 0
- if l.offset > 0 {
- for i := firstIdx; i <= lastIdx; i++ {
- itemPos := l.getItemPosition(i)
- if itemPos >= l.offset {
- stabilizeIdx = i
- stabilizeY = itemPos
- break
- }
- }
- }
-
- // Track if any heights changed during rendering
- heightsChanged := false
-
- // Render visible items that aren't cached (measurement pass)
- for i := firstIdx; i <= lastIdx; i++ {
- if _, cached := l.renderedCache[i]; !cached {
- oldHeight := l.itemHeights[i].height
- l.renderItem(i)
- if l.itemHeights[i].height != oldHeight {
- heightsChanged = true
- }
- } else if l.dirtyItems[i] {
- // Re-render dirty items
- oldHeight := l.itemHeights[i].height
- l.renderItem(i)
- delete(l.dirtyItems, i)
- if l.itemHeights[i].height != oldHeight {
- heightsChanged = true
- }
- }
- }
-
- // If heights changed, adjust offset to keep stabilization point stable
- if heightsChanged && stabilizeIdx >= 0 {
- newStabilizeY := l.getItemPosition(stabilizeIdx)
- offsetDelta := newStabilizeY - stabilizeY
-
- // Adjust offset to maintain visual stability
- l.offset += offsetDelta
- l.clampOffset()
-
- // Re-find visible items with adjusted positions
- firstIdx, lastIdx = l.findVisibleItems()
-
- // Render any newly visible items after position adjustments
- for i := firstIdx; i <= lastIdx; i++ {
- if _, cached := l.renderedCache[i]; !cached {
- l.renderItem(i)
- }
- }
- }
-
- // Clear old cache entries outside visible range
- if len(l.renderedCache) > (lastIdx-firstIdx+1)*2 {
- l.pruneCache(firstIdx, lastIdx)
- }
-
- // Composite visible items into viewport with stable positions
- l.drawViewport(scr, area, firstIdx, lastIdx)
-
- l.dirtyViewport = false
- l.needsLayout = false
-}
-
-// drawViewport composites visible items into the screen.
-func (l *LazyList) drawViewport(scr uv.Screen, area uv.Rectangle, firstIdx, lastIdx int) {
- screen.ClearArea(scr, area)
-
- itemStartY := l.getItemPosition(firstIdx)
-
- for i := firstIdx; i <= lastIdx; i++ {
- cached, ok := l.renderedCache[i]
- if !ok {
- continue
- }
-
- // Calculate where this item appears in viewport
- itemY := itemStartY - l.offset
- itemHeight := cached.height
-
- // Skip if entirely above viewport
- if itemY+itemHeight < 0 {
- itemStartY += itemHeight
- continue
- }
-
- // Stop if entirely below viewport
- if itemY >= l.height {
- break
- }
-
- // Calculate visible portion of item
- srcStartY := 0
- dstStartY := itemY
-
- if itemY < 0 {
- // Item starts above viewport
- srcStartY = -itemY
- dstStartY = 0
- }
-
- srcEndY := srcStartY + (l.height - dstStartY)
- if srcEndY > itemHeight {
- srcEndY = itemHeight
- }
-
- // Copy visible lines from item buffer to screen
- buf := cached.buffer.Buffer
- destY := area.Min.Y + dstStartY
-
- for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
- if srcY >= buf.Height() {
- break
- }
-
- line := buf.Line(srcY)
- destX := area.Min.X
-
- for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
- cell := line.At(x)
- scr.SetCell(destX, destY, cell)
- destX++
- }
- destY++
- }
-
- itemStartY += itemHeight
- }
-}
-
-// pruneCache removes cached items outside the visible range.
-func (l *LazyList) pruneCache(firstIdx, lastIdx int) {
- keepStart := max(0, firstIdx-l.overscan*2)
- keepEnd := min(len(l.items)-1, lastIdx+l.overscan*2)
-
- for idx := range l.renderedCache {
- if idx < keepStart || idx > keepEnd {
- delete(l.renderedCache, idx)
- }
- }
-}
-
-// clampOffset ensures scroll offset stays within valid bounds.
-func (l *LazyList) clampOffset() {
- maxOffset := l.totalHeight - l.height
- if maxOffset < 0 {
- maxOffset = 0
- }
-
- if l.offset > maxOffset {
- l.offset = maxOffset
- }
- if l.offset < 0 {
- l.offset = 0
- }
-}
-
-// SetItems replaces all items in the list.
-func (l *LazyList) SetItems(items []Item) {
- l.items = items
- l.itemHeights = make([]itemHeight, len(items))
- l.renderedCache = make(map[int]*renderedItemCache)
- l.dirtyItems = make(map[int]bool)
-
- // Initialize with estimates
- for i := range l.items {
- l.itemHeights[i] = itemHeight{
- height: l.defaultEstimate,
- measured: false,
- }
- }
- l.calculateTotalHeight()
- l.needsLayout = true
- l.dirtyViewport = true
-}
-
-// AppendItem adds an item to the end of the list.
-func (l *LazyList) AppendItem(item Item) {
- l.items = append(l.items, item)
- l.itemHeights = append(l.itemHeights, itemHeight{
- height: l.defaultEstimate,
- measured: false,
- })
- l.totalHeight += l.defaultEstimate
- l.dirtyViewport = true
-}
-
-// PrependItem adds an item to the beginning of the list.
-func (l *LazyList) PrependItem(item Item) {
- l.items = append([]Item{item}, l.items...)
- l.itemHeights = append([]itemHeight{{
- height: l.defaultEstimate,
- measured: false,
- }}, l.itemHeights...)
-
- // Shift cache indices
- newCache := make(map[int]*renderedItemCache)
- for idx, cached := range l.renderedCache {
- newCache[idx+1] = cached
- }
- l.renderedCache = newCache
-
- l.totalHeight += l.defaultEstimate
- l.offset += l.defaultEstimate // Maintain scroll position
- l.dirtyViewport = true
-}
-
-// UpdateItem replaces an item at the given index.
-func (l *LazyList) UpdateItem(idx int, item Item) {
- if idx < 0 || idx >= len(l.items) {
- return
- }
-
- l.items[idx] = item
- delete(l.renderedCache, idx)
- l.dirtyItems[idx] = true
- // Keep height estimate - will remeasure on next render
- l.dirtyViewport = true
-}
-
-// ScrollBy scrolls by the given number of lines.
-func (l *LazyList) ScrollBy(delta int) {
- l.offset += delta
- l.clampOffset()
- l.dirtyViewport = true
-}
-
-// ScrollToBottom scrolls to the end of the list.
-func (l *LazyList) ScrollToBottom() {
- l.offset = l.totalHeight - l.height
- l.clampOffset()
- l.dirtyViewport = true
-}
-
-// ScrollToTop scrolls to the beginning of the list.
-func (l *LazyList) ScrollToTop() {
- l.offset = 0
- l.dirtyViewport = true
-}
-
-// Len returns the number of items in the list.
-func (l *LazyList) Len() int {
- return len(l.items)
-}
-
-// Focus sets the list as focused.
-func (l *LazyList) Focus() {
- l.focused = true
- l.focusSelectedItem()
- l.dirtyViewport = true
-}
-
-// Blur removes focus from the list.
-func (l *LazyList) Blur() {
- l.focused = false
- l.blurSelectedItem()
- l.dirtyViewport = true
-}
-
-// focusSelectedItem focuses the currently selected item if it's focusable.
-func (l *LazyList) focusSelectedItem() {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return
- }
-
- item := l.items[l.selectedIdx]
- if f, ok := item.(Focusable); ok {
- f.Focus()
- delete(l.renderedCache, l.selectedIdx)
- l.dirtyItems[l.selectedIdx] = true
- }
-}
-
-// blurSelectedItem blurs the currently selected item if it's focusable.
-func (l *LazyList) blurSelectedItem() {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return
- }
-
- item := l.items[l.selectedIdx]
- if f, ok := item.(Focusable); ok {
- f.Blur()
- delete(l.renderedCache, l.selectedIdx)
- l.dirtyItems[l.selectedIdx] = true
- }
-}
-
-// IsFocused returns whether the list is focused.
-func (l *LazyList) IsFocused() bool {
- return l.focused
-}
-
-// Width returns the current viewport width.
-func (l *LazyList) Width() int {
- return l.width
-}
-
-// Height returns the current viewport height.
-func (l *LazyList) Height() int {
- return l.height
-}
-
-// SetSize sets the viewport size explicitly.
-// This is useful when you want to pre-configure the list size before drawing.
-func (l *LazyList) SetSize(width, height int) {
- widthChanged := l.width != width
- heightChanged := l.height != height
-
- l.width = width
- l.height = height
-
- // Width changes invalidate all cached renders
- if widthChanged && width > 0 {
- l.renderedCache = make(map[int]*renderedItemCache)
- // Mark all heights as needing remeasurement
- for i := range l.itemHeights {
- l.itemHeights[i].measured = false
- l.itemHeights[i].height = l.defaultEstimate
- }
- l.calculateTotalHeight()
- l.needsLayout = true
- l.dirtyViewport = true
- }
-
- if heightChanged && height > 0 {
- l.clampOffset()
- l.dirtyViewport = true
- }
-
- // After cache invalidation, scroll to selected item or bottom
- if widthChanged || heightChanged {
- if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
- // Scroll to selected item
- l.ScrollToSelected()
- } else if len(l.items) > 0 {
- // No selection - scroll to bottom
- l.ScrollToBottom()
- }
- }
-}
-
-// Selection methods
-
-// Selected returns the currently selected item index (-1 if none).
-func (l *LazyList) Selected() int {
- return l.selectedIdx
-}
-
-// SetSelected sets the selected item by index.
-func (l *LazyList) SetSelected(idx int) {
- if idx < -1 || idx >= len(l.items) {
- return
- }
-
- if l.selectedIdx != idx {
- prevIdx := l.selectedIdx
- l.selectedIdx = idx
- l.dirtyViewport = true
-
- // Update focus states if list is focused.
- if l.focused {
- // Blur previously selected item.
- if prevIdx >= 0 && prevIdx < len(l.items) {
- if f, ok := l.items[prevIdx].(Focusable); ok {
- f.Blur()
- delete(l.renderedCache, prevIdx)
- l.dirtyItems[prevIdx] = true
- }
- }
-
- // Focus newly selected item.
- if idx >= 0 && idx < len(l.items) {
- if f, ok := l.items[idx].(Focusable); ok {
- f.Focus()
- delete(l.renderedCache, idx)
- l.dirtyItems[idx] = true
- }
- }
- }
- }
-}
-
-// SelectPrev selects the previous item.
-func (l *LazyList) SelectPrev() {
- if len(l.items) == 0 {
- return
- }
-
- if l.selectedIdx <= 0 {
- l.selectedIdx = 0
- } else {
- l.selectedIdx--
- }
-
- l.dirtyViewport = true
-}
-
-// SelectNext selects the next item.
-func (l *LazyList) SelectNext() {
- if len(l.items) == 0 {
- return
- }
-
- if l.selectedIdx < 0 {
- l.selectedIdx = 0
- } else if l.selectedIdx < len(l.items)-1 {
- l.selectedIdx++
- }
-
- l.dirtyViewport = true
-}
-
-// SelectFirst selects the first item.
-func (l *LazyList) SelectFirst() {
- if len(l.items) > 0 {
- l.selectedIdx = 0
- l.dirtyViewport = true
- }
-}
-
-// SelectLast selects the last item.
-func (l *LazyList) SelectLast() {
- if len(l.items) > 0 {
- l.selectedIdx = len(l.items) - 1
- l.dirtyViewport = true
- }
-}
-
-// SelectFirstInView selects the first visible item in the viewport.
-func (l *LazyList) SelectFirstInView() {
- if len(l.items) == 0 {
- return
- }
-
- firstIdx, _ := l.findVisibleItems()
- l.selectedIdx = firstIdx
- l.dirtyViewport = true
-}
-
-// SelectLastInView selects the last visible item in the viewport.
-func (l *LazyList) SelectLastInView() {
- if len(l.items) == 0 {
- return
- }
-
- _, lastIdx := l.findVisibleItems()
- l.selectedIdx = lastIdx
- l.dirtyViewport = true
-}
-
-// SelectedItemInView returns whether the selected item is visible in the viewport.
-func (l *LazyList) SelectedItemInView() bool {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return false
- }
-
- firstIdx, lastIdx := l.findVisibleItems()
- return l.selectedIdx >= firstIdx && l.selectedIdx <= lastIdx
-}
-
-// ScrollToSelected scrolls the viewport to ensure the selected item is visible.
-func (l *LazyList) ScrollToSelected() {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return
- }
-
- // Get selected item position
- itemY := l.getItemPosition(l.selectedIdx)
- itemHeight := l.itemHeights[l.selectedIdx].height
-
- // Check if item is above viewport
- if itemY < l.offset {
- l.offset = itemY
- l.dirtyViewport = true
- return
- }
-
- // Check if item is below viewport
- itemBottom := itemY + itemHeight
- viewportBottom := l.offset + l.height
-
- if itemBottom > viewportBottom {
- // Scroll so item bottom is at viewport bottom
- l.offset = itemBottom - l.height
- l.clampOffset()
- l.dirtyViewport = true
- }
-}
-
-// Mouse interaction methods
-
-// HandleMouseDown handles mouse button down events.
-// Returns true if the event was handled.
-func (l *LazyList) HandleMouseDown(x, y int) bool {
- if x < 0 || y < 0 || x >= l.width || y >= l.height {
- return false
- }
-
- // Find which item was clicked
- clickY := l.offset + y
- itemIdx := l.findItemAtY(clickY)
-
- if itemIdx < 0 {
- return false
- }
-
- // Calculate item-relative Y position.
- itemY := clickY - l.getItemPosition(itemIdx)
-
- l.mouseDown = true
- l.mouseDownItem = itemIdx
- l.mouseDownX = x
- l.mouseDownY = itemY
- l.mouseDragItem = itemIdx
- l.mouseDragX = x
- l.mouseDragY = itemY
-
- // Select the clicked item
- l.SetSelected(itemIdx)
-
- return true
-}
-
-// HandleMouseDrag handles mouse drag events.
-func (l *LazyList) HandleMouseDrag(x, y int) {
- if !l.mouseDown {
- return
- }
-
- // Find item under cursor
- if y >= 0 && y < l.height {
- dragY := l.offset + y
- itemIdx := l.findItemAtY(dragY)
- if itemIdx >= 0 {
- l.mouseDragItem = itemIdx
- // Calculate item-relative Y position.
- l.mouseDragY = dragY - l.getItemPosition(itemIdx)
- l.mouseDragX = x
- }
- }
-
- // Update highlight if item supports it.
- l.updateHighlight()
-}
-
-// HandleMouseUp handles mouse button up events.
-func (l *LazyList) HandleMouseUp(x, y int) {
- if !l.mouseDown {
- return
- }
-
- l.mouseDown = false
-
- // Final highlight update.
- l.updateHighlight()
-}
-
-// findItemAtY finds the item index at the given Y coordinate (in content space, not viewport).
-func (l *LazyList) findItemAtY(y int) int {
- if y < 0 || len(l.items) == 0 {
- return -1
- }
-
- pos := 0
- for i := 0; i < len(l.items); i++ {
- itemHeight := l.itemHeights[i].height
- if y >= pos && y < pos+itemHeight {
- return i
- }
- pos += itemHeight
- }
-
- return -1
-}
-
-// updateHighlight updates the highlight range for highlightable items.
-// Supports highlighting within a single item and respects drag direction.
-func (l *LazyList) updateHighlight() {
- if l.mouseDownItem < 0 {
- return
- }
-
- // Get start and end item indices.
- downItemIdx := l.mouseDownItem
- dragItemIdx := l.mouseDragItem
-
- // Determine selection direction.
- draggingDown := dragItemIdx > downItemIdx ||
- (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
- (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
-
- // Determine actual start and end based on direction.
- var startItemIdx, endItemIdx int
- var startLine, startCol, endLine, endCol int
-
- if draggingDown {
- // Normal forward selection.
- startItemIdx = downItemIdx
- endItemIdx = dragItemIdx
- startLine = l.mouseDownY
- startCol = l.mouseDownX
- endLine = l.mouseDragY
- endCol = l.mouseDragX
- } else {
- // Backward selection (dragging up).
- startItemIdx = dragItemIdx
- endItemIdx = downItemIdx
- startLine = l.mouseDragY
- startCol = l.mouseDragX
- endLine = l.mouseDownY
- endCol = l.mouseDownX
- }
-
- // Clear all highlights first.
- for i, item := range l.items {
- if h, ok := item.(Highlightable); ok {
- h.SetHighlight(-1, -1, -1, -1)
- delete(l.renderedCache, i)
- l.dirtyItems[i] = true
- }
- }
-
- // Highlight all items in range.
- for idx := startItemIdx; idx <= endItemIdx; idx++ {
- item, ok := l.items[idx].(Highlightable)
- if !ok {
- continue
- }
-
- if idx == startItemIdx && idx == endItemIdx {
- // Single item selection.
- item.SetHighlight(startLine, startCol, endLine, endCol)
- } else if idx == startItemIdx {
- // First item - from start position to end of item.
- itemHeight := l.itemHeights[idx].height
- item.SetHighlight(startLine, startCol, itemHeight-1, 9999) // 9999 = end of line
- } else if idx == endItemIdx {
- // Last item - from start of item to end position.
- item.SetHighlight(0, 0, endLine, endCol)
- } else {
- // Middle item - fully highlighted.
- itemHeight := l.itemHeights[idx].height
- item.SetHighlight(0, 0, itemHeight-1, 9999)
- }
-
- delete(l.renderedCache, idx)
- l.dirtyItems[idx] = true
- }
-}
-
-// ClearHighlight clears any active text highlighting.
-func (l *LazyList) ClearHighlight() {
- for i, item := range l.items {
- if h, ok := item.(Highlightable); ok {
- h.SetHighlight(-1, -1, -1, -1)
- delete(l.renderedCache, i)
- l.dirtyItems[i] = true
- }
- }
- l.mouseDownItem = -1
- l.mouseDragItem = -1
-}
-
-// GetHighlightedText returns the plain text content of all highlighted regions
-// across items, without any styling. Returns empty string if no highlights exist.
-func (l *LazyList) GetHighlightedText() string {
- var result strings.Builder
-
- // Iterate through items to find highlighted ones.
- for i, item := range l.items {
- h, ok := item.(Highlightable)
- if !ok {
- continue
- }
-
- startLine, startCol, endLine, endCol := h.GetHighlight()
- if startLine < 0 {
- continue
- }
-
- // Ensure item is rendered so we can access its buffer.
- if _, ok := l.renderedCache[i]; !ok {
- l.renderItem(i)
- }
-
- cached := l.renderedCache[i]
- if cached == nil || cached.buffer == nil {
- continue
- }
-
- buf := cached.buffer
- itemHeight := cached.height
-
- // Extract text from highlighted region in item buffer.
- for y := startLine; y <= endLine && y < itemHeight; y++ {
- if y >= buf.Height() {
- break
- }
-
- line := buf.Line(y)
-
- // Determine column range for this line.
- colStart := 0
- if y == startLine {
- colStart = startCol
- }
-
- colEnd := len(line)
- if y == endLine {
- colEnd = min(endCol, len(line))
- }
-
- // Track last non-empty position to trim trailing spaces.
- lastContentX := -1
- for x := colStart; x < colEnd && x < len(line); x++ {
- cell := line.At(x)
- if cell == nil || cell.IsZero() {
- continue
- }
- if cell.Content != "" && cell.Content != " " {
- lastContentX = x
- }
- }
-
- // Extract text from cells, up to last content.
- endX := colEnd
- if lastContentX >= 0 {
- endX = lastContentX + 1
- }
-
- for x := colStart; x < endX && x < len(line); x++ {
- cell := line.At(x)
- if cell != nil && !cell.IsZero() {
- result.WriteString(cell.Content)
- }
- }
-
- // Add newline if not the last line.
- if y < endLine {
- result.WriteString("\n")
- }
- }
-
- // Add newline between items if this isn't the last highlighted item.
- if i < len(l.items)-1 {
- nextHasHighlight := false
- for j := i + 1; j < len(l.items); j++ {
- if h, ok := l.items[j].(Highlightable); ok {
- s, _, _, _ := h.GetHighlight()
- if s >= 0 {
- nextHasHighlight = true
- break
- }
- }
- }
- if nextHasHighlight {
- result.WriteString("\n")
- }
- }
- }
-
- return result.String()
-}
-
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
-
-func max(a, b int) int {
- if a > b {
- return a
- }
- return b
-}
@@ -1,1126 +0,0 @@
-package list
-
-import (
- "strings"
-
- uv "github.com/charmbracelet/ultraviolet"
- "github.com/charmbracelet/ultraviolet/screen"
- "github.com/charmbracelet/x/exp/ordered"
-)
-
-// List is a scrollable list component that implements uv.Drawable.
-// It efficiently manages a large number of items by caching rendered content
-// in a master buffer and extracting only the visible viewport when drawn.
-type List struct {
- // Configuration
- width, height int
-
- // Data
- items []Item
-
- // Focus & Selection
- focused bool
- selectedIdx int // Currently selected item index (-1 if none)
-
- // Master buffer containing ALL rendered items
- masterBuffer *uv.ScreenBuffer
- totalHeight int
-
- // Item positioning in master buffer
- itemPositions []itemPosition
-
- // Viewport state
- offset int // Scroll offset in lines from top
-
- // Mouse state
- mouseDown bool
- mouseDownItem int // Item index where mouse was pressed
- mouseDownX int // X position in item content (character offset)
- mouseDownY int // Y position in item (line offset)
- mouseDragItem int // Current item index being dragged over
- mouseDragX int // Current X in item content
- mouseDragY int // Current Y in item
-
- // Dirty tracking
- dirty bool
- dirtyItems map[int]bool
-}
-
-type itemPosition struct {
- startLine int
- height int
-}
-
-// New creates a new list with the given items.
-func New(items ...Item) *List {
- l := &List{
- items: items,
- itemPositions: make([]itemPosition, len(items)),
- dirtyItems: make(map[int]bool),
- selectedIdx: -1,
- mouseDownItem: -1,
- mouseDragItem: -1,
- }
-
- l.dirty = true
- return l
-}
-
-// ensureBuilt ensures the master buffer is built.
-// This is called by methods that need itemPositions or totalHeight.
-func (l *List) ensureBuilt() {
- if l.width <= 0 || l.height <= 0 {
- return
- }
-
- if l.dirty {
- l.rebuildMasterBuffer()
- } else if len(l.dirtyItems) > 0 {
- l.updateDirtyItems()
- }
-}
-
-// Draw implements uv.Drawable.
-// Draws the visible viewport of the list to the given screen buffer.
-func (l *List) Draw(scr uv.Screen, area uv.Rectangle) {
- if area.Dx() <= 0 || area.Dy() <= 0 {
- return
- }
-
- // Update internal dimensions if area size changed
- widthChanged := l.width != area.Dx()
- heightChanged := l.height != area.Dy()
-
- l.width = area.Dx()
- l.height = area.Dy()
-
- // Only width changes require rebuilding master buffer
- // Height changes only affect viewport clipping, not item rendering
- if widthChanged {
- l.dirty = true
- }
-
- // Height changes require clamping offset to new bounds
- if heightChanged {
- l.clampOffset()
- }
-
- if len(l.items) == 0 {
- screen.ClearArea(scr, area)
- return
- }
-
- // Ensure buffer is built
- l.ensureBuilt()
-
- // Draw visible portion to the target screen
- l.drawViewport(scr, area)
-}
-
-// Render renders the visible viewport to a string.
-// This is a convenience method that creates a temporary screen buffer,
-// draws to it, and returns the rendered string.
-func (l *List) Render() string {
- if l.width <= 0 || l.height <= 0 {
- return ""
- }
-
- if len(l.items) == 0 {
- return ""
- }
-
- // Ensure buffer is built
- l.ensureBuilt()
-
- // Extract visible lines directly from master buffer
- return l.renderViewport()
-}
-
-// renderViewport renders the visible portion of the master buffer to a string.
-func (l *List) renderViewport() string {
- if l.masterBuffer == nil {
- return ""
- }
-
- buf := l.masterBuffer.Buffer
-
- // Calculate visible region in master buffer
- srcStartY := l.offset
- srcEndY := l.offset + l.height
-
- // Clamp to actual buffer bounds
- if srcStartY >= len(buf.Lines) {
- // Beyond end of content, return empty lines
- emptyLine := strings.Repeat(" ", l.width)
- lines := make([]string, l.height)
- for i := range lines {
- lines[i] = emptyLine
- }
- return strings.Join(lines, "\n")
- }
- if srcEndY > len(buf.Lines) {
- srcEndY = len(buf.Lines)
- }
-
- // Build result with proper line handling
- lines := make([]string, l.height)
- lineIdx := 0
-
- // Render visible lines from buffer
- for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ {
- lines[lineIdx] = buf.Lines[y].Render()
- lineIdx++
- }
-
- // Pad remaining lines with spaces to maintain viewport height
- emptyLine := strings.Repeat(" ", l.width)
- for ; lineIdx < l.height; lineIdx++ {
- lines[lineIdx] = emptyLine
- }
-
- return strings.Join(lines, "\n")
-}
-
-// drawViewport draws the visible portion from master buffer to target screen.
-func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) {
- if l.masterBuffer == nil {
- screen.ClearArea(scr, area)
- return
- }
-
- buf := l.masterBuffer.Buffer
-
- // Calculate visible region in master buffer
- srcStartY := l.offset
- srcEndY := l.offset + area.Dy()
-
- // Clamp to actual buffer bounds
- if srcStartY >= buf.Height() {
- screen.ClearArea(scr, area)
- return
- }
- if srcEndY > buf.Height() {
- srcEndY = buf.Height()
- }
-
- // Copy visible lines to target screen
- destY := area.Min.Y
- for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
- line := buf.Line(srcY)
- destX := area.Min.X
-
- for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
- cell := line.At(x)
- scr.SetCell(destX, destY, cell)
- destX++
- }
- destY++
- }
-
- // Clear any remaining area if content is shorter than viewport
- if destY < area.Max.Y {
- clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY)
- screen.ClearArea(scr, clearArea)
- }
-}
-
-// rebuildMasterBuffer composes all items into the master buffer.
-func (l *List) rebuildMasterBuffer() {
- if len(l.items) == 0 {
- l.totalHeight = 0
- l.dirty = false
- return
- }
-
- // Calculate total height
- l.totalHeight = l.calculateTotalHeight()
-
- // Create or resize master buffer
- if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight {
- buf := uv.NewScreenBuffer(l.width, l.totalHeight)
- l.masterBuffer = &buf
- }
-
- // Clear buffer
- screen.Clear(l.masterBuffer)
-
- // Draw each item
- currentY := 0
- for i, item := range l.items {
- itemHeight := item.Height(l.width)
-
- // Draw item to master buffer
- area := uv.Rect(0, currentY, l.width, itemHeight)
- item.Draw(l.masterBuffer, area)
-
- // Store position
- l.itemPositions[i] = itemPosition{
- startLine: currentY,
- height: itemHeight,
- }
-
- // Advance position
- currentY += itemHeight
- }
-
- l.dirty = false
- l.dirtyItems = make(map[int]bool)
-}
-
-// updateDirtyItems efficiently updates only changed items using slice operations.
-func (l *List) updateDirtyItems() {
- if len(l.dirtyItems) == 0 {
- return
- }
-
- // Check if all dirty items have unchanged heights
- allSameHeight := true
- for idx := range l.dirtyItems {
- item := l.items[idx]
- pos := l.itemPositions[idx]
- newHeight := item.Height(l.width)
- if newHeight != pos.height {
- allSameHeight = false
- break
- }
- }
-
- // Optimization: If all dirty items have unchanged heights, re-render in place
- if allSameHeight {
- buf := l.masterBuffer.Buffer
- for idx := range l.dirtyItems {
- item := l.items[idx]
- pos := l.itemPositions[idx]
-
- // Clear the item's area
- for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ {
- buf.Lines[y] = uv.NewLine(l.width)
- }
-
- // Re-render item
- area := uv.Rect(0, pos.startLine, l.width, pos.height)
- item.Draw(l.masterBuffer, area)
- }
-
- l.dirtyItems = make(map[int]bool)
- return
- }
-
- // Height changed - full rebuild
- l.dirty = true
- l.dirtyItems = make(map[int]bool)
- l.rebuildMasterBuffer()
-}
-
-// updatePositionsBelow updates the startLine for all items below the given index.
-func (l *List) updatePositionsBelow(fromIdx int, delta int) {
- for i := fromIdx + 1; i < len(l.items); i++ {
- pos := l.itemPositions[i]
- pos.startLine += delta
- l.itemPositions[i] = pos
- }
-}
-
-// calculateTotalHeight calculates the total height of all items plus gaps.
-func (l *List) calculateTotalHeight() int {
- if len(l.items) == 0 {
- return 0
- }
-
- total := 0
- for _, item := range l.items {
- total += item.Height(l.width)
- }
- return total
-}
-
-// SetSize updates the viewport size.
-func (l *List) SetSize(width, height int) {
- widthChanged := l.width != width
- heightChanged := l.height != height
-
- l.width = width
- l.height = height
-
- // Width changes require full rebuild (items may reflow)
- if widthChanged {
- l.dirty = true
- }
-
- // Height changes require clamping offset to new bounds
- if heightChanged {
- l.clampOffset()
- }
-}
-
-// Height returns the current viewport height.
-func (l *List) Height() int {
- return l.height
-}
-
-// Width returns the current viewport width.
-func (l *List) Width() int {
- return l.width
-}
-
-// GetSize returns the current viewport size.
-func (l *List) GetSize() (int, int) {
- return l.width, l.height
-}
-
-// Len returns the number of items in the list.
-func (l *List) Len() int {
- return len(l.items)
-}
-
-// SetItems replaces all items in the list.
-func (l *List) SetItems(items []Item) {
- l.items = items
- l.itemPositions = make([]itemPosition, len(items))
- l.dirty = true
-}
-
-// Items returns all items in the list.
-func (l *List) Items() []Item {
- return l.items
-}
-
-// AppendItem adds an item to the end of the list. Returns true if successful.
-func (l *List) AppendItem(item Item) bool {
- l.items = append(l.items, item)
- l.itemPositions = append(l.itemPositions, itemPosition{})
-
- // If buffer not built yet, mark dirty for full rebuild
- if l.masterBuffer == nil || l.width <= 0 {
- l.dirty = true
- return true
- }
-
- // Process any pending dirty items before modifying buffer structure
- if len(l.dirtyItems) > 0 {
- l.updateDirtyItems()
- }
-
- // Efficient append: insert lines at end of buffer
- itemHeight := item.Height(l.width)
- startLine := l.totalHeight
-
- // Expand buffer
- newLines := make([]uv.Line, itemHeight)
- for i := range newLines {
- newLines[i] = uv.NewLine(l.width)
- }
- l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...)
-
- // Draw new item
- area := uv.Rect(0, startLine, l.width, itemHeight)
- item.Draw(l.masterBuffer, area)
-
- // Update tracking
- l.itemPositions[len(l.items)-1] = itemPosition{
- startLine: startLine,
- height: itemHeight,
- }
- l.totalHeight += itemHeight
-
- return true
-}
-
-// PrependItem adds an item to the beginning of the list. Returns true if
-// successful.
-func (l *List) PrependItem(item Item) bool {
- l.items = append([]Item{item}, l.items...)
- l.itemPositions = append([]itemPosition{{}}, l.itemPositions...)
- if l.selectedIdx >= 0 {
- l.selectedIdx++
- }
-
- // If buffer not built yet, mark dirty for full rebuild
- if l.masterBuffer == nil || l.width <= 0 {
- l.dirty = true
- return true
- }
-
- // Process any pending dirty items before modifying buffer structure
- if len(l.dirtyItems) > 0 {
- l.updateDirtyItems()
- }
-
- // Efficient prepend: insert lines at start of buffer
- itemHeight := item.Height(l.width)
-
- // Create new lines
- newLines := make([]uv.Line, itemHeight)
- for i := range newLines {
- newLines[i] = uv.NewLine(l.width)
- }
-
- // Insert at beginning
- buf := l.masterBuffer.Buffer
- buf.Lines = append(newLines, buf.Lines...)
-
- // Draw new item
- area := uv.Rect(0, 0, l.width, itemHeight)
- item.Draw(l.masterBuffer, area)
-
- // Update all positions (shift everything down)
- for i := range l.itemPositions {
- pos := l.itemPositions[i]
- pos.startLine += itemHeight
- l.itemPositions[i] = pos
- }
-
- // Add position for new item at start
- l.itemPositions[0] = itemPosition{
- startLine: 0,
- height: itemHeight,
- }
-
- l.totalHeight += itemHeight
-
- return true
-}
-
-// UpdateItem replaces an item with the same index. Returns true if successful.
-func (l *List) UpdateItem(idx int, item Item) bool {
- if idx < 0 || idx >= len(l.items) {
- return false
- }
- l.items[idx] = item
- l.dirtyItems[idx] = true
- return true
-}
-
-// DeleteItem removes an item by index. Returns true if successful.
-func (l *List) DeleteItem(idx int) bool {
- if idx < 0 || idx >= len(l.items) {
- return false
- }
-
- // Get position before deleting
- pos := l.itemPositions[idx]
-
- // Process any pending dirty items before modifying buffer structure
- if len(l.dirtyItems) > 0 {
- l.updateDirtyItems()
- }
-
- l.items = append(l.items[:idx], l.items[idx+1:]...)
- l.itemPositions = append(l.itemPositions[:idx], l.itemPositions[idx+1:]...)
-
- // Adjust selection
- if l.selectedIdx == idx {
- if idx > 0 {
- l.selectedIdx = idx - 1
- } else if len(l.items) > 0 {
- l.selectedIdx = 0
- } else {
- l.selectedIdx = -1
- }
- } else if l.selectedIdx > idx {
- l.selectedIdx--
- }
-
- // If buffer not built yet, mark dirty for full rebuild
- if l.masterBuffer == nil {
- l.dirty = true
- return true
- }
-
- // Efficient delete: remove lines from buffer
- deleteStart := pos.startLine
- deleteEnd := pos.startLine + pos.height
- buf := l.masterBuffer.Buffer
-
- if deleteEnd <= len(buf.Lines) {
- buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...)
- l.totalHeight -= pos.height
- l.updatePositionsBelow(idx-1, -pos.height)
- } else {
- // Position data corrupt, rebuild
- l.dirty = true
- }
-
- return true
-}
-
-// Focus focuses the list and the selected item (if focusable).
-func (l *List) Focus() {
- l.focused = true
- l.focusSelectedItem()
-}
-
-// Blur blurs the list and the selected item (if focusable).
-func (l *List) Blur() {
- l.focused = false
- l.blurSelectedItem()
-}
-
-// Focused returns whether the list is focused.
-func (l *List) Focused() bool {
- return l.focused
-}
-
-// SetSelected sets the selected item by ID.
-func (l *List) SetSelected(idx int) {
- if idx < 0 || idx >= len(l.items) {
- return
- }
- if l.selectedIdx == idx {
- return
- }
-
- prevIdx := l.selectedIdx
- l.selectedIdx = idx
-
- // Update focus states if list is focused
- if l.focused {
- if prevIdx >= 0 && prevIdx < len(l.items) {
- if f, ok := l.items[prevIdx].(Focusable); ok {
- f.Blur()
- l.dirtyItems[prevIdx] = true
- }
- }
-
- if f, ok := l.items[idx].(Focusable); ok {
- f.Focus()
- l.dirtyItems[idx] = true
- }
- }
-}
-
-// SelectFirst selects the first item in the list.
-func (l *List) SelectFirst() {
- l.SetSelected(0)
-}
-
-// SelectLast selects the last item in the list.
-func (l *List) SelectLast() {
- l.SetSelected(len(l.items) - 1)
-}
-
-// SelectNextWrap selects the next item in the list (wraps to beginning).
-// When the list is focused, skips non-focusable items.
-func (l *List) SelectNextWrap() {
- l.selectNext(true)
-}
-
-// SelectNext selects the next item in the list (no wrap).
-// When the list is focused, skips non-focusable items.
-func (l *List) SelectNext() {
- l.selectNext(false)
-}
-
-func (l *List) selectNext(wrap bool) {
- if len(l.items) == 0 {
- return
- }
-
- startIdx := l.selectedIdx
- for i := 0; i < len(l.items); i++ {
- var nextIdx int
- if wrap {
- nextIdx = (startIdx + 1 + i) % len(l.items)
- } else {
- nextIdx = startIdx + 1 + i
- if nextIdx >= len(l.items) {
- return
- }
- }
-
- // If list is focused and item is not focusable, skip it
- if l.focused {
- if _, ok := l.items[nextIdx].(Focusable); !ok {
- continue
- }
- }
-
- // Select and scroll to this item
- l.SetSelected(nextIdx)
- return
- }
-}
-
-// SelectPrevWrap selects the previous item in the list (wraps to end).
-// When the list is focused, skips non-focusable items.
-func (l *List) SelectPrevWrap() {
- l.selectPrev(true)
-}
-
-// SelectPrev selects the previous item in the list (no wrap).
-// When the list is focused, skips non-focusable items.
-func (l *List) SelectPrev() {
- l.selectPrev(false)
-}
-
-func (l *List) selectPrev(wrap bool) {
- if len(l.items) == 0 {
- return
- }
-
- startIdx := l.selectedIdx
- for i := 0; i < len(l.items); i++ {
- var prevIdx int
- if wrap {
- prevIdx = (startIdx - 1 - i + len(l.items)) % len(l.items)
- } else {
- prevIdx = startIdx - 1 - i
- if prevIdx < 0 {
- return
- }
- }
-
- // If list is focused and item is not focusable, skip it
- if l.focused {
- if _, ok := l.items[prevIdx].(Focusable); !ok {
- continue
- }
- }
-
- // Select and scroll to this item
- l.SetSelected(prevIdx)
- return
- }
-}
-
-// SelectedItem returns the currently selected item, or nil if none.
-func (l *List) SelectedItem() Item {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return nil
- }
- return l.items[l.selectedIdx]
-}
-
-// SelectedIndex returns the index of the currently selected item, or -1 if none.
-func (l *List) SelectedIndex() int {
- return l.selectedIdx
-}
-
-// AtBottom returns whether the viewport is scrolled to the bottom.
-func (l *List) AtBottom() bool {
- l.ensureBuilt()
- return l.offset >= l.totalHeight-l.height
-}
-
-// AtTop returns whether the viewport is scrolled to the top.
-func (l *List) AtTop() bool {
- return l.offset <= 0
-}
-
-// ScrollBy scrolls the viewport by the given number of lines.
-// Positive values scroll down, negative scroll up.
-func (l *List) ScrollBy(deltaLines int) {
- l.offset += deltaLines
- l.clampOffset()
-}
-
-// ScrollToTop scrolls to the top of the list.
-func (l *List) ScrollToTop() {
- l.offset = 0
-}
-
-// ScrollToBottom scrolls to the bottom of the list.
-func (l *List) ScrollToBottom() {
- l.ensureBuilt()
- if l.totalHeight > l.height {
- l.offset = l.totalHeight - l.height
- } else {
- l.offset = 0
- }
-}
-
-// ScrollToItem scrolls to make the item with the given ID visible.
-func (l *List) ScrollToItem(idx int) {
- l.ensureBuilt()
- pos := l.itemPositions[idx]
- itemStart := pos.startLine
- itemEnd := pos.startLine + pos.height
- viewStart := l.offset
- viewEnd := l.offset + l.height
-
- // Check if item is already fully visible
- if itemStart >= viewStart && itemEnd <= viewEnd {
- return
- }
-
- // Scroll to show item
- if itemStart < viewStart {
- l.offset = itemStart
- } else if itemEnd > viewEnd {
- l.offset = itemEnd - l.height
- }
-
- l.clampOffset()
-}
-
-// ScrollToSelected scrolls to make the selected item visible.
-func (l *List) ScrollToSelected() {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return
- }
- l.ScrollToItem(l.selectedIdx)
-}
-
-// Offset returns the current scroll offset.
-func (l *List) Offset() int {
- return l.offset
-}
-
-// TotalHeight returns the total height of all items including gaps.
-func (l *List) TotalHeight() int {
- return l.totalHeight
-}
-
-// SelectFirstInView selects the first item that is fully visible in the viewport.
-func (l *List) SelectFirstInView() {
- l.ensureBuilt()
-
- viewportStart := l.offset
- viewportEnd := l.offset + l.height
-
- for i := range l.items {
- pos := l.itemPositions[i]
-
- // Check if item is fully within viewport bounds
- if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
- l.SetSelected(i)
- return
- }
- }
-}
-
-// SelectLastInView selects the last item that is fully visible in the viewport.
-func (l *List) SelectLastInView() {
- l.ensureBuilt()
-
- viewportStart := l.offset
- viewportEnd := l.offset + l.height
-
- for i := len(l.items) - 1; i >= 0; i-- {
- pos := l.itemPositions[i]
-
- // Check if item is fully within viewport bounds
- if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
- l.SetSelected(i)
- return
- }
- }
-}
-
-// SelectedItemInView returns true if the selected item is currently visible in the viewport.
-func (l *List) SelectedItemInView() bool {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return false
- }
-
- // Get selected item ID and position
- pos := l.itemPositions[l.selectedIdx]
-
- // Check if item is within viewport bounds
- viewportStart := l.offset
- viewportEnd := l.offset + l.height
-
- // Item is visible if any part of it overlaps with the viewport
- return pos.startLine < viewportEnd && (pos.startLine+pos.height) > viewportStart
-}
-
-// clampOffset ensures offset is within valid bounds.
-func (l *List) clampOffset() {
- l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height)
-}
-
-// focusSelectedItem focuses the currently selected item if it's focusable.
-func (l *List) focusSelectedItem() {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return
- }
-
- item := l.items[l.selectedIdx]
- if f, ok := item.(Focusable); ok {
- f.Focus()
- l.dirtyItems[l.selectedIdx] = true
- }
-}
-
-// blurSelectedItem blurs the currently selected item if it's focusable.
-func (l *List) blurSelectedItem() {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return
- }
-
- item := l.items[l.selectedIdx]
- if f, ok := item.(Focusable); ok {
- f.Blur()
- l.dirtyItems[l.selectedIdx] = true
- }
-}
-
-// HandleMouseDown handles mouse button press events.
-// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
-// Returns true if the event was handled.
-func (l *List) HandleMouseDown(x, y int) bool {
- l.ensureBuilt()
-
- // Convert viewport y to master buffer y
- bufferY := y + l.offset
-
- // Find which item was clicked
- itemIdx, itemY := l.findItemAtPosition(bufferY)
- if itemIdx < 0 {
- return false
- }
-
- // Calculate x position within item content
- // For now, x is just the viewport x coordinate
- // Items can interpret this as character offset in their content
-
- l.mouseDown = true
- l.mouseDownItem = itemIdx
- l.mouseDownX = x
- l.mouseDownY = itemY
- l.mouseDragItem = itemIdx
- l.mouseDragX = x
- l.mouseDragY = itemY
-
- // Select the clicked item
- l.SetSelected(itemIdx)
-
- return true
-}
-
-// HandleMouseDrag handles mouse drag events during selection.
-// x and y are viewport-relative coordinates.
-// Returns true if the event was handled.
-func (l *List) HandleMouseDrag(x, y int) bool {
- if !l.mouseDown {
- return false
- }
-
- l.ensureBuilt()
-
- // Convert viewport y to master buffer y
- bufferY := y + l.offset
-
- // Find which item we're dragging over
- itemIdx, itemY := l.findItemAtPosition(bufferY)
- if itemIdx < 0 {
- return false
- }
-
- l.mouseDragItem = itemIdx
- l.mouseDragX = x
- l.mouseDragY = itemY
-
- // Update highlight if item supports it
- l.updateHighlight()
-
- return true
-}
-
-// HandleMouseUp handles mouse button release events.
-// Returns true if the event was handled.
-func (l *List) HandleMouseUp(x, y int) bool {
- if !l.mouseDown {
- return false
- }
-
- l.mouseDown = false
-
- // Final highlight update
- l.updateHighlight()
-
- return true
-}
-
-// ClearHighlight clears any active text highlighting.
-func (l *List) ClearHighlight() {
- for i, item := range l.items {
- if h, ok := item.(Highlightable); ok {
- h.SetHighlight(-1, -1, -1, -1)
- l.dirtyItems[i] = true
- }
- }
- l.mouseDownItem = -1
- l.mouseDragItem = -1
-}
-
-// findItemAtPosition finds the item at the given master buffer y coordinate.
-// Returns the item index and the y offset within that item. It returns -1, -1
-// if no item is found.
-func (l *List) findItemAtPosition(bufferY int) (itemIdx int, itemY int) {
- if bufferY < 0 || bufferY >= l.totalHeight {
- return -1, -1
- }
-
- // Linear search through items to find which one contains this y
- // This could be optimized with binary search if needed
- for i := range l.items {
- pos := l.itemPositions[i]
- if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height {
- return i, bufferY - pos.startLine
- }
- }
-
- return -1, -1
-}
-
-// updateHighlight updates the highlight range for highlightable items.
-// Supports highlighting across multiple items and respects drag direction.
-func (l *List) updateHighlight() {
- if l.mouseDownItem < 0 {
- return
- }
-
- // Get start and end item indices
- downItemIdx := l.mouseDownItem
- dragItemIdx := l.mouseDragItem
-
- // Determine selection direction
- draggingDown := dragItemIdx > downItemIdx ||
- (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
- (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
-
- // Determine actual start and end based on direction
- var startItemIdx, endItemIdx int
- var startLine, startCol, endLine, endCol int
-
- if draggingDown {
- // Normal forward selection
- startItemIdx = downItemIdx
- endItemIdx = dragItemIdx
- startLine = l.mouseDownY
- startCol = l.mouseDownX
- endLine = l.mouseDragY
- endCol = l.mouseDragX
- } else {
- // Backward selection (dragging up)
- startItemIdx = dragItemIdx
- endItemIdx = downItemIdx
- startLine = l.mouseDragY
- startCol = l.mouseDragX
- endLine = l.mouseDownY
- endCol = l.mouseDownX
- }
-
- // Clear all highlights first
- for i, item := range l.items {
- if h, ok := item.(Highlightable); ok {
- h.SetHighlight(-1, -1, -1, -1)
- l.dirtyItems[i] = true
- }
- }
-
- // Highlight all items in range
- for idx := startItemIdx; idx <= endItemIdx; idx++ {
- item, ok := l.items[idx].(Highlightable)
- if !ok {
- continue
- }
-
- if idx == startItemIdx && idx == endItemIdx {
- // Single item selection
- item.SetHighlight(startLine, startCol, endLine, endCol)
- } else if idx == startItemIdx {
- // First item - from start position to end of item
- pos := l.itemPositions[idx]
- item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line
- } else if idx == endItemIdx {
- // Last item - from start of item to end position
- item.SetHighlight(0, 0, endLine, endCol)
- } else {
- // Middle item - fully highlighted
- pos := l.itemPositions[idx]
- item.SetHighlight(0, 0, pos.height-1, 9999)
- }
-
- l.dirtyItems[idx] = true
- }
-}
-
-// GetHighlightedText returns the plain text content of all highlighted regions
-// across items, without any styling. Returns empty string if no highlights exist.
-func (l *List) GetHighlightedText() string {
- l.ensureBuilt()
-
- if l.masterBuffer == nil {
- return ""
- }
-
- var result strings.Builder
-
- // Iterate through items to find highlighted ones
- for i, item := range l.items {
- h, ok := item.(Highlightable)
- if !ok {
- continue
- }
-
- startLine, startCol, endLine, endCol := h.GetHighlight()
- if startLine < 0 {
- continue
- }
-
- pos := l.itemPositions[i]
-
- // Extract text from highlighted region in master buffer
- for y := startLine; y <= endLine && y < pos.height; y++ {
- bufferY := pos.startLine + y
- if bufferY >= l.masterBuffer.Height() {
- break
- }
-
- line := l.masterBuffer.Line(bufferY)
-
- // Determine column range for this line
- colStart := 0
- if y == startLine {
- colStart = startCol
- }
-
- colEnd := len(line)
- if y == endLine {
- colEnd = min(endCol, len(line))
- }
-
- // Track last non-empty position to trim trailing spaces
- lastContentX := -1
- for x := colStart; x < colEnd && x < len(line); x++ {
- cell := line.At(x)
- if cell == nil || cell.IsZero() {
- continue
- }
- if cell.Content != "" && cell.Content != " " {
- lastContentX = x
- }
- }
-
- // Extract text from cells using String() method, up to last content
- endX := colEnd
- if lastContentX >= 0 {
- endX = lastContentX + 1
- }
-
- for x := colStart; x < endX && x < len(line); x++ {
- cell := line.At(x)
- if cell == nil || cell.IsZero() {
- continue
- }
- result.WriteString(cell.String())
- }
-
- // Add newline between lines (but not after the last line)
- if y < endLine && y < pos.height-1 {
- result.WriteRune('\n')
- }
- }
-
- // Add newline between items if there are more highlighted items
- if result.Len() > 0 {
- result.WriteRune('\n')
- }
- }
-
- // Trim trailing newline if present
- text := result.String()
- return strings.TrimSuffix(text, "\n")
-}
@@ -1,578 +0,0 @@
-package list
-
-import (
- "strings"
- "testing"
-
- "charm.land/lipgloss/v2"
- uv "github.com/charmbracelet/ultraviolet"
- "github.com/stretchr/testify/require"
-)
-
-func TestNewList(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- NewStringItem("Item 3"),
- }
-
- l := New(items...)
- l.SetSize(80, 24)
-
- if len(l.items) != 3 {
- t.Errorf("expected 3 items, got %d", len(l.items))
- }
-
- if l.width != 80 || l.height != 24 {
- t.Errorf("expected size 80x24, got %dx%d", l.width, l.height)
- }
-}
-
-func TestListDraw(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- NewStringItem("Item 3"),
- }
-
- l := New(items...)
- l.SetSize(80, 10)
-
- // Create a screen buffer to draw into
- screen := uv.NewScreenBuffer(80, 10)
- area := uv.Rect(0, 0, 80, 10)
-
- // Draw the list
- l.Draw(&screen, area)
-
- // Verify the buffer has content
- output := screen.Render()
- if len(output) == 0 {
- t.Error("expected non-empty output")
- }
-}
-
-func TestListAppendItem(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- }
-
- l := New(items...)
- l.AppendItem(NewStringItem("Item 2"))
-
- if len(l.items) != 2 {
- t.Errorf("expected 2 items after append, got %d", len(l.items))
- }
-}
-
-func TestListDeleteItem(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- NewStringItem("Item 3"),
- }
-
- l := New(items...)
- l.DeleteItem(2)
-
- if len(l.items) != 2 {
- t.Errorf("expected 2 items after delete, got %d", len(l.items))
- }
-}
-
-func TestListUpdateItem(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- }
-
- l := New(items...)
- l.SetSize(80, 10)
-
- // Update item
- newItem := NewStringItem("Updated Item 2")
- l.UpdateItem(1, newItem)
-
- if l.items[1].(*StringItem).content != "Updated Item 2" {
- t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content)
- }
-}
-
-func TestListSelection(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- NewStringItem("Item 3"),
- }
-
- l := New(items...)
- l.SetSelected(0)
-
- if l.SelectedIndex() != 0 {
- t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
- }
-
- l.SelectNext()
- if l.SelectedIndex() != 1 {
- t.Errorf("expected selected index 1 after SelectNext, got %d", l.SelectedIndex())
- }
-
- l.SelectPrev()
- if l.SelectedIndex() != 0 {
- t.Errorf("expected selected index 0 after SelectPrev, got %d", l.SelectedIndex())
- }
-}
-
-func TestListScrolling(t *testing.T) {
- items := []Item{
- NewStringItem("Item 1"),
- NewStringItem("Item 2"),
- NewStringItem("Item 3"),
- NewStringItem("Item 4"),
- NewStringItem("Item 5"),
- }
-
- l := New(items...)
- l.SetSize(80, 2) // Small viewport
-
- // Draw to initialize the master buffer
- screen := uv.NewScreenBuffer(80, 2)
- area := uv.Rect(0, 0, 80, 2)
- l.Draw(&screen, area)
-
- if l.Offset() != 0 {
- t.Errorf("expected initial offset 0, got %d", l.Offset())
- }
-
- l.ScrollBy(2)
- if l.Offset() != 2 {
- t.Errorf("expected offset 2 after ScrollBy(2), got %d", l.Offset())
- }
-
- l.ScrollToTop()
- if l.Offset() != 0 {
- t.Errorf("expected offset 0 after ScrollToTop, got %d", l.Offset())
- }
-}
-
-// FocusableTestItem is a test item that implements Focusable.
-type FocusableTestItem struct {
- id string
- content string
- focused bool
-}
-
-func (f *FocusableTestItem) ID() string {
- return f.id
-}
-
-func (f *FocusableTestItem) Height(width int) int {
- return 1
-}
-
-func (f *FocusableTestItem) Draw(scr uv.Screen, area uv.Rectangle) {
- prefix := "[ ]"
- if f.focused {
- prefix = "[X]"
- }
- content := prefix + " " + f.content
- styled := uv.NewStyledString(content)
- styled.Draw(scr, area)
-}
-
-func (f *FocusableTestItem) Focus() {
- f.focused = true
-}
-
-func (f *FocusableTestItem) Blur() {
- f.focused = false
-}
-
-func (f *FocusableTestItem) IsFocused() bool {
- return f.focused
-}
-
-func TestListFocus(t *testing.T) {
- items := []Item{
- &FocusableTestItem{id: "1", content: "Item 1"},
- &FocusableTestItem{id: "2", content: "Item 2"},
- }
-
- l := New(items...)
- l.SetSize(80, 10)
- l.SetSelected(0)
-
- // Focus the list
- l.Focus()
-
- if !l.Focused() {
- t.Error("expected list to be focused")
- }
-
- // Check if selected item is focused
- selectedItem := l.SelectedItem().(*FocusableTestItem)
- if !selectedItem.IsFocused() {
- t.Error("expected selected item to be focused")
- }
-
- // Select next and check focus changes
- l.SelectNext()
- if selectedItem.IsFocused() {
- t.Error("expected previous item to be blurred")
- }
-
- newSelectedItem := l.SelectedItem().(*FocusableTestItem)
- if !newSelectedItem.IsFocused() {
- t.Error("expected new selected item to be focused")
- }
-
- // Blur the list
- l.Blur()
- if l.Focused() {
- t.Error("expected list to be blurred")
- }
-}
-
-// TestFocusNavigationAfterAppendingToViewportHeight reproduces the bug:
-// Append items until viewport is full, select last, then navigate backwards.
-func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
- t.Parallel()
-
- focusStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("86"))
-
- blurStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("240"))
-
- // Start with one item
- items := []Item{
- NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
- }
-
- l := New(items...)
- l.SetSize(20, 15) // 15 lines viewport height
- l.SetSelected(0)
- l.Focus()
-
- // Initial draw to build buffer
- screen := uv.NewScreenBuffer(20, 15)
- l.Draw(&screen, uv.Rect(0, 0, 20, 15))
-
- // Append items until we exceed viewport height
- // Each focusable item with border is 5 lines tall
- for i := 2; i <= 4; i++ {
- item := NewStringItem("Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
- l.AppendItem(item)
- }
-
- // Select the last item
- l.SetSelected(3)
-
- // Draw
- screen = uv.NewScreenBuffer(20, 15)
- l.Draw(&screen, uv.Rect(0, 0, 20, 15))
- output := screen.Render()
-
- t.Logf("After selecting last item:\n%s", output)
- require.Contains(t, output, "38;5;86", "expected focus color on last item")
-
- // Now navigate backwards
- l.SelectPrev()
-
- screen = uv.NewScreenBuffer(20, 15)
- l.Draw(&screen, uv.Rect(0, 0, 20, 15))
- output = screen.Render()
-
- t.Logf("After SelectPrev:\n%s", output)
- require.Contains(t, output, "38;5;86", "expected focus color after SelectPrev")
-
- // Navigate backwards again
- l.SelectPrev()
-
- screen = uv.NewScreenBuffer(20, 15)
- l.Draw(&screen, uv.Rect(0, 0, 20, 15))
- output = screen.Render()
-
- t.Logf("After second SelectPrev:\n%s", output)
- require.Contains(t, output, "38;5;86", "expected focus color after second SelectPrev")
-}
-
-func TestFocusableItemUpdate(t *testing.T) {
- // Create styles with borders
- focusStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("86"))
-
- blurStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("240"))
-
- // Create a focusable item
- item := NewStringItem("Test Item").WithFocusStyles(&focusStyle, &blurStyle)
-
- // Initially not focused - render with blur style
- screen1 := uv.NewScreenBuffer(20, 5)
- area := uv.Rect(0, 0, 20, 5)
- item.Draw(&screen1, area)
- output1 := screen1.Render()
-
- // Focus the item
- item.Focus()
-
- // Render again - should show focus style
- screen2 := uv.NewScreenBuffer(20, 5)
- item.Draw(&screen2, area)
- output2 := screen2.Render()
-
- // Outputs should be different (different border colors)
- if output1 == output2 {
- t.Error("expected different output after focusing, but got same output")
- }
-
- // Verify focus state
- if !item.IsFocused() {
- t.Error("expected item to be focused")
- }
-
- // Blur the item
- item.Blur()
-
- // Render again - should show blur style again
- screen3 := uv.NewScreenBuffer(20, 5)
- item.Draw(&screen3, area)
- output3 := screen3.Render()
-
- // Output should match original blur output
- if output1 != output3 {
- t.Error("expected same output after blurring as initial state")
- }
-
- // Verify blur state
- if item.IsFocused() {
- t.Error("expected item to be blurred")
- }
-}
-
-func TestFocusableItemHeightWithBorder(t *testing.T) {
- // Create a style with a border (adds 2 to vertical height)
- borderStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder())
-
- // Item without styles has height 1
- plainItem := NewStringItem("Test")
- plainHeight := plainItem.Height(20)
- if plainHeight != 1 {
- t.Errorf("expected plain height 1, got %d", plainHeight)
- }
-
- // Item with border should add border height (2 lines)
- item := NewStringItem("Test").WithFocusStyles(&borderStyle, &borderStyle)
- itemHeight := item.Height(20)
- expectedHeight := 1 + 2 // content + border
- if itemHeight != expectedHeight {
- t.Errorf("expected height %d (content 1 + border 2), got %d",
- expectedHeight, itemHeight)
- }
-}
-
-func TestFocusableItemInList(t *testing.T) {
- focusStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("86"))
-
- blurStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("240"))
-
- // Create list with focusable items
- items := []Item{
- NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
- NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
- NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
- }
-
- l := New(items...)
- l.SetSize(80, 20)
- l.SetSelected(0)
-
- // Focus the list
- l.Focus()
-
- // First item should be focused
- firstItem := items[0].(*StringItem)
- if !firstItem.IsFocused() {
- t.Error("expected first item to be focused after focusing list")
- }
-
- // Render to ensure changes are visible
- output1 := l.Render()
- if !strings.Contains(output1, "Item 1") {
- t.Error("expected output to contain first item")
- }
-
- // Select second item
- l.SetSelected(1)
-
- // First item should be blurred, second focused
- if firstItem.IsFocused() {
- t.Error("expected first item to be blurred after changing selection")
- }
-
- secondItem := items[1].(*StringItem)
- if !secondItem.IsFocused() {
- t.Error("expected second item to be focused after selection")
- }
-
- // Render again - should show updated focus
- output2 := l.Render()
- if !strings.Contains(output2, "Item 2") {
- t.Error("expected output to contain second item")
- }
-
- // Outputs should be different
- if output1 == output2 {
- t.Error("expected different output after selection change")
- }
-}
-
-func TestFocusableItemWithNilStyles(t *testing.T) {
- // Test with nil styles - should render inner item directly
- item := NewStringItem("Plain Item").WithFocusStyles(nil, nil)
-
- // Height should be based on content (no border since styles are nil)
- itemHeight := item.Height(20)
- if itemHeight != 1 {
- t.Errorf("expected height 1 (no border), got %d", itemHeight)
- }
-
- // Draw should work without styles
- screen := uv.NewScreenBuffer(20, 5)
- area := uv.Rect(0, 0, 20, 5)
- item.Draw(&screen, area)
- output := screen.Render()
-
- // Should contain the inner content
- if !strings.Contains(output, "Plain Item") {
- t.Error("expected output to contain inner item content")
- }
-
- // Focus/blur should still work but not change appearance
- item.Focus()
- screen2 := uv.NewScreenBuffer(20, 5)
- item.Draw(&screen2, area)
- output2 := screen2.Render()
-
- // Output should be identical since no styles
- if output != output2 {
- t.Error("expected same output with nil styles whether focused or not")
- }
-
- if !item.IsFocused() {
- t.Error("expected item to be focused")
- }
-}
-
-func TestFocusableItemWithOnlyFocusStyle(t *testing.T) {
- // Test with only focus style (blur is nil)
- focusStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("86"))
-
- item := NewStringItem("Test").WithFocusStyles(&focusStyle, nil)
-
- // When not focused, should use nil blur style (no border)
- screen1 := uv.NewScreenBuffer(20, 5)
- area := uv.Rect(0, 0, 20, 5)
- item.Draw(&screen1, area)
- output1 := screen1.Render()
-
- // Focus the item
- item.Focus()
- screen2 := uv.NewScreenBuffer(20, 5)
- item.Draw(&screen2, area)
- output2 := screen2.Render()
-
- // Outputs should be different (focused has border, blurred doesn't)
- if output1 == output2 {
- t.Error("expected different output when only focus style is set")
- }
-}
-
-func TestFocusableItemLastLineNotEaten(t *testing.T) {
- // Create focusable items with borders
- focusStyle := lipgloss.NewStyle().
- Padding(1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("86"))
-
- blurStyle := lipgloss.NewStyle().
- BorderForeground(lipgloss.Color("240"))
-
- items := []Item{
- NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
- Gap,
- NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
- Gap,
- NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
- Gap,
- NewStringItem("Item 4").WithFocusStyles(&focusStyle, &blurStyle),
- Gap,
- NewStringItem("Item 5").WithFocusStyles(&focusStyle, &blurStyle),
- }
-
- // Items with padding(1) and border are 5 lines each
- // Viewport of 10 lines fits exactly 2 items
- l := New()
- l.SetSize(20, 10)
-
- for _, item := range items {
- l.AppendItem(item)
- }
-
- // Focus the list
- l.Focus()
-
- // Select last item
- l.SetSelected(len(items) - 1)
-
- // Scroll to bottom
- l.ScrollToBottom()
-
- output := l.Render()
-
- t.Logf("Output:\n%s", output)
- t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
-
- // Select previous - will skip gaps and go to Item 4
- l.SelectPrev()
-
- output = l.Render()
-
- t.Logf("Output:\n%s", output)
- t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
-
- // Should show items 3 (unfocused), 4 (focused), and part of 5 (unfocused)
- if !strings.Contains(output, "Item 3") {
- t.Error("expected output to contain 'Item 3'")
- }
- if !strings.Contains(output, "Item 4") {
- t.Error("expected output to contain 'Item 4'")
- }
- if !strings.Contains(output, "Item 5") {
- t.Error("expected output to contain 'Item 5'")
- }
-
- // Count bottom borders - should have 1 (focused item 4)
- bottomBorderCount := 0
- for _, line := range strings.Split(output, "\r\n") {
- if strings.Contains(line, "╰") || strings.Contains(line, "└") {
- bottomBorderCount++
- }
- }
-
- if bottomBorderCount != 1 {
- t.Errorf("expected 1 bottom border (focused item 4), got %d", bottomBorderCount)
- }
-}
@@ -1,972 +0,0 @@
-package list
-
-import (
- "strings"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- uv "github.com/charmbracelet/ultraviolet"
- "github.com/charmbracelet/x/exp/ordered"
-)
-
-const maxGapSize = 100
-
-var newlineBuffer = strings.Repeat("\n", maxGapSize)
-
-// SimpleList is a string-based list with virtual scrolling behavior.
-// Based on exp/list but simplified for our needs.
-type SimpleList struct {
- // Viewport dimensions.
- width, height int
-
- // Scroll offset (in lines from top).
- offset int
-
- // Items.
- items []Item
- itemIDs map[string]int // ID -> index mapping
-
- // Rendered content (all items stacked).
- rendered string
- renderedHeight int // Total height of rendered content in lines
- lineOffsets []int // Byte offsets for each line (for fast slicing)
-
- // Rendered item metadata.
- renderedItems map[string]renderedItem
-
- // Selection.
- selectedIdx int
- focused bool
-
- // Focus tracking.
- prevSelectedIdx int
-
- // Mouse/highlight state.
- mouseDown bool
- mouseDownItem int
- mouseDownX int
- mouseDownY int // viewport-relative Y
- mouseDragItem int
- mouseDragX int
- mouseDragY int // viewport-relative Y
- selectionStartLine int
- selectionStartCol int
- selectionEndLine int
- selectionEndCol int
-
- // Configuration.
- gap int // Gap between items in lines
-}
-
-type renderedItem struct {
- view string
- height int
- start int // Start line in rendered content
- end int // End line in rendered content
-}
-
-// NewSimpleList creates a new simple list.
-func NewSimpleList(items ...Item) *SimpleList {
- l := &SimpleList{
- items: items,
- itemIDs: make(map[string]int, len(items)),
- renderedItems: make(map[string]renderedItem),
- selectedIdx: -1,
- prevSelectedIdx: -1,
- gap: 0,
- selectionStartLine: -1,
- selectionStartCol: -1,
- selectionEndLine: -1,
- selectionEndCol: -1,
- }
-
- // Build ID map.
- for i, item := range items {
- if idItem, ok := item.(interface{ ID() string }); ok {
- l.itemIDs[idItem.ID()] = i
- }
- }
-
- return l
-}
-
-// Init initializes the list (Bubbletea lifecycle).
-func (l *SimpleList) Init() tea.Cmd {
- return l.render()
-}
-
-// Update handles messages (Bubbletea lifecycle).
-func (l *SimpleList) Update(msg tea.Msg) (*SimpleList, tea.Cmd) {
- return l, nil
-}
-
-// View returns the visible viewport (Bubbletea lifecycle).
-func (l *SimpleList) View() string {
- if l.height <= 0 || l.width <= 0 {
- return ""
- }
-
- start, end := l.viewPosition()
- viewStart := max(0, start)
- viewEnd := end
-
- if viewStart > viewEnd {
- return ""
- }
-
- view := l.getLines(viewStart, viewEnd)
-
- // Apply width/height constraints.
- view = lipgloss.NewStyle().
- Height(l.height).
- Width(l.width).
- Render(view)
-
- // Apply highlighting if active.
- if l.hasSelection() {
- return l.renderSelection(view)
- }
-
- return view
-}
-
-// viewPosition returns the start and end line indices for the viewport.
-func (l *SimpleList) viewPosition() (int, int) {
- start := max(0, l.offset)
- end := min(l.offset+l.height-1, l.renderedHeight-1)
- start = min(start, end)
- return start, end
-}
-
-// getLines returns lines [start, end] from rendered content.
-func (l *SimpleList) getLines(start, end int) string {
- if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
- return ""
- }
-
- if end >= len(l.lineOffsets) {
- end = len(l.lineOffsets) - 1
- }
- if start > end {
- return ""
- }
-
- startOffset := l.lineOffsets[start]
- var endOffset int
- if end+1 < len(l.lineOffsets) {
- endOffset = l.lineOffsets[end+1] - 1 // Exclude newline
- } else {
- endOffset = len(l.rendered)
- }
-
- if startOffset >= len(l.rendered) {
- return ""
- }
- endOffset = min(endOffset, len(l.rendered))
-
- return l.rendered[startOffset:endOffset]
-}
-
-// render rebuilds the rendered content from all items.
-func (l *SimpleList) render() tea.Cmd {
- if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
- return nil
- }
-
- // Set default selection if none.
- if l.selectedIdx < 0 && len(l.items) > 0 {
- l.selectedIdx = 0
- }
-
- // Handle focus changes.
- var focusCmd tea.Cmd
- if l.focused {
- focusCmd = l.focusSelectedItem()
- } else {
- focusCmd = l.blurSelectedItem()
- }
-
- // Render all items.
- var b strings.Builder
- currentLine := 0
-
- for i, item := range l.items {
- // Render item.
- view := l.renderItem(item)
- height := lipgloss.Height(view)
-
- // Store metadata.
- rItem := renderedItem{
- view: view,
- height: height,
- start: currentLine,
- end: currentLine + height - 1,
- }
-
- if idItem, ok := item.(interface{ ID() string }); ok {
- l.renderedItems[idItem.ID()] = rItem
- }
-
- // Append to rendered content.
- b.WriteString(view)
-
- // Add gap after item (except last).
- gap := l.gap
- if i == len(l.items)-1 {
- gap = 0
- }
-
- if gap > 0 {
- if gap <= maxGapSize {
- b.WriteString(newlineBuffer[:gap])
- } else {
- b.WriteString(strings.Repeat("\n", gap))
- }
- }
-
- currentLine += height + gap
- }
-
- l.setRendered(b.String())
-
- // Scroll to selected item.
- if l.focused && l.selectedIdx >= 0 {
- l.scrollToSelection()
- }
-
- return focusCmd
-}
-
-// renderItem renders a single item.
-func (l *SimpleList) renderItem(item Item) string {
- // Create a buffer for the item.
- buf := uv.NewScreenBuffer(l.width, 1000) // Max height
- area := uv.Rect(0, 0, l.width, 1000)
- item.Draw(&buf, area)
-
- // Find actual height.
- height := l.measureBufferHeight(&buf)
- if height == 0 {
- height = 1
- }
-
- // Render to string.
- return buf.Render()
-}
-
-// measureBufferHeight finds the actual content height in a buffer.
-func (l *SimpleList) measureBufferHeight(buf *uv.ScreenBuffer) int {
- height := buf.Height()
-
- // Scan from bottom up to find last non-empty line.
- for y := height - 1; y >= 0; y-- {
- line := buf.Line(y)
- if l.lineHasContent(line) {
- return y + 1
- }
- }
-
- return 0
-}
-
-// lineHasContent checks if a line has any non-empty cells.
-func (l *SimpleList) lineHasContent(line uv.Line) bool {
- for x := 0; x < len(line); x++ {
- cell := line.At(x)
- if cell != nil && !cell.IsZero() && cell.Content != "" && cell.Content != " " {
- return true
- }
- }
- return false
-}
-
-// setRendered updates the rendered content and caches line offsets.
-func (l *SimpleList) setRendered(rendered string) {
- l.rendered = rendered
- l.renderedHeight = lipgloss.Height(rendered)
-
- // Build line offset cache.
- if len(rendered) > 0 {
- l.lineOffsets = make([]int, 0, l.renderedHeight)
- l.lineOffsets = append(l.lineOffsets, 0)
-
- offset := 0
- for {
- idx := strings.IndexByte(rendered[offset:], '\n')
- if idx == -1 {
- break
- }
- offset += idx + 1
- l.lineOffsets = append(l.lineOffsets, offset)
- }
- } else {
- l.lineOffsets = nil
- }
-}
-
-// scrollToSelection scrolls to make the selected item visible.
-func (l *SimpleList) scrollToSelection() {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return
- }
-
- // Get selected item metadata.
- var rItem *renderedItem
- if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
- if ri, ok := l.renderedItems[idItem.ID()]; ok {
- rItem = &ri
- }
- }
-
- if rItem == nil {
- return
- }
-
- start, end := l.viewPosition()
-
- // Already visible.
- if rItem.start >= start && rItem.end <= end {
- return
- }
-
- // Item is above viewport - scroll up.
- if rItem.start < start {
- l.offset = rItem.start
- return
- }
-
- // Item is below viewport - scroll down.
- if rItem.end > end {
- l.offset = max(0, rItem.end-l.height+1)
- }
-}
-
-// Focus/blur management.
-
-func (l *SimpleList) focusSelectedItem() tea.Cmd {
- if l.selectedIdx < 0 || !l.focused {
- return nil
- }
-
- var cmds []tea.Cmd
-
- // Blur previous.
- if l.prevSelectedIdx >= 0 && l.prevSelectedIdx != l.selectedIdx && l.prevSelectedIdx < len(l.items) {
- if f, ok := l.items[l.prevSelectedIdx].(Focusable); ok && f.IsFocused() {
- f.Blur()
- }
- }
-
- // Focus current.
- if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
- if f, ok := l.items[l.selectedIdx].(Focusable); ok && !f.IsFocused() {
- f.Focus()
- }
- }
-
- l.prevSelectedIdx = l.selectedIdx
- return tea.Batch(cmds...)
-}
-
-func (l *SimpleList) blurSelectedItem() tea.Cmd {
- if l.selectedIdx < 0 || l.focused {
- return nil
- }
-
- if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
- if f, ok := l.items[l.selectedIdx].(Focusable); ok && f.IsFocused() {
- f.Blur()
- }
- }
-
- return nil
-}
-
-// Public API.
-
-// SetSize sets the viewport dimensions.
-func (l *SimpleList) SetSize(width, height int) tea.Cmd {
- oldWidth := l.width
- l.width = width
- l.height = height
-
- if oldWidth != width {
- // Width changed - need to re-render.
- return l.render()
- }
-
- return nil
-}
-
-// Width returns the viewport width.
-func (l *SimpleList) Width() int {
- return l.width
-}
-
-// Height returns the viewport height.
-func (l *SimpleList) Height() int {
- return l.height
-}
-
-// GetSize returns the viewport dimensions.
-func (l *SimpleList) GetSize() (int, int) {
- return l.width, l.height
-}
-
-// Items returns all items.
-func (l *SimpleList) Items() []Item {
- return l.items
-}
-
-// Len returns the number of items.
-func (l *SimpleList) Len() int {
- return len(l.items)
-}
-
-// SetItems replaces all items.
-func (l *SimpleList) SetItems(items []Item) tea.Cmd {
- l.items = items
- l.itemIDs = make(map[string]int, len(items))
- l.renderedItems = make(map[string]renderedItem)
- l.selectedIdx = -1
- l.prevSelectedIdx = -1
- l.offset = 0
-
- // Build ID map.
- for i, item := range items {
- if idItem, ok := item.(interface{ ID() string }); ok {
- l.itemIDs[idItem.ID()] = i
- }
- }
-
- return l.render()
-}
-
-// AppendItem adds an item to the end.
-func (l *SimpleList) AppendItem(item Item) tea.Cmd {
- l.items = append(l.items, item)
-
- if idItem, ok := item.(interface{ ID() string }); ok {
- l.itemIDs[idItem.ID()] = len(l.items) - 1
- }
-
- return l.render()
-}
-
-// PrependItem adds an item to the beginning.
-func (l *SimpleList) PrependItem(item Item) tea.Cmd {
- l.items = append([]Item{item}, l.items...)
-
- // Rebuild ID map (indices shifted).
- l.itemIDs = make(map[string]int, len(l.items))
- for i, it := range l.items {
- if idItem, ok := it.(interface{ ID() string }); ok {
- l.itemIDs[idItem.ID()] = i
- }
- }
-
- // Adjust selection.
- if l.selectedIdx >= 0 {
- l.selectedIdx++
- }
- if l.prevSelectedIdx >= 0 {
- l.prevSelectedIdx++
- }
-
- return l.render()
-}
-
-// UpdateItem replaces an item at the given index.
-func (l *SimpleList) UpdateItem(idx int, item Item) tea.Cmd {
- if idx < 0 || idx >= len(l.items) {
- return nil
- }
-
- l.items[idx] = item
-
- // Update ID map.
- if idItem, ok := item.(interface{ ID() string }); ok {
- l.itemIDs[idItem.ID()] = idx
- }
-
- return l.render()
-}
-
-// DeleteItem removes an item at the given index.
-func (l *SimpleList) DeleteItem(idx int) tea.Cmd {
- if idx < 0 || idx >= len(l.items) {
- return nil
- }
-
- l.items = append(l.items[:idx], l.items[idx+1:]...)
-
- // Rebuild ID map (indices shifted).
- l.itemIDs = make(map[string]int, len(l.items))
- for i, it := range l.items {
- if idItem, ok := it.(interface{ ID() string }); ok {
- l.itemIDs[idItem.ID()] = i
- }
- }
-
- // Adjust selection.
- if l.selectedIdx == idx {
- if idx > 0 {
- l.selectedIdx = idx - 1
- } else if len(l.items) > 0 {
- l.selectedIdx = 0
- } else {
- l.selectedIdx = -1
- }
- } else if l.selectedIdx > idx {
- l.selectedIdx--
- }
-
- if l.prevSelectedIdx == idx {
- l.prevSelectedIdx = -1
- } else if l.prevSelectedIdx > idx {
- l.prevSelectedIdx--
- }
-
- return l.render()
-}
-
-// Focus sets the list as focused.
-func (l *SimpleList) Focus() tea.Cmd {
- l.focused = true
- return l.render()
-}
-
-// Blur removes focus from the list.
-func (l *SimpleList) Blur() tea.Cmd {
- l.focused = false
- return l.render()
-}
-
-// Focused returns whether the list is focused.
-func (l *SimpleList) Focused() bool {
- return l.focused
-}
-
-// Selection.
-
-// Selected returns the currently selected item index.
-func (l *SimpleList) Selected() int {
- return l.selectedIdx
-}
-
-// SelectedIndex returns the currently selected item index.
-func (l *SimpleList) SelectedIndex() int {
- return l.selectedIdx
-}
-
-// SelectedItem returns the currently selected item.
-func (l *SimpleList) SelectedItem() Item {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return nil
- }
- return l.items[l.selectedIdx]
-}
-
-// SetSelected sets the selected item by index.
-func (l *SimpleList) SetSelected(idx int) tea.Cmd {
- if idx < -1 || idx >= len(l.items) {
- return nil
- }
-
- if l.selectedIdx == idx {
- return nil
- }
-
- l.prevSelectedIdx = l.selectedIdx
- l.selectedIdx = idx
-
- return l.render()
-}
-
-// SelectFirst selects the first item.
-func (l *SimpleList) SelectFirst() tea.Cmd {
- return l.SetSelected(0)
-}
-
-// SelectLast selects the last item.
-func (l *SimpleList) SelectLast() tea.Cmd {
- if len(l.items) > 0 {
- return l.SetSelected(len(l.items) - 1)
- }
- return nil
-}
-
-// SelectNext selects the next item.
-func (l *SimpleList) SelectNext() tea.Cmd {
- if l.selectedIdx < len(l.items)-1 {
- return l.SetSelected(l.selectedIdx + 1)
- }
- return nil
-}
-
-// SelectPrev selects the previous item.
-func (l *SimpleList) SelectPrev() tea.Cmd {
- if l.selectedIdx > 0 {
- return l.SetSelected(l.selectedIdx - 1)
- }
- return nil
-}
-
-// SelectNextWrap selects the next item (wraps to beginning).
-func (l *SimpleList) SelectNextWrap() tea.Cmd {
- if len(l.items) == 0 {
- return nil
- }
- nextIdx := (l.selectedIdx + 1) % len(l.items)
- return l.SetSelected(nextIdx)
-}
-
-// SelectPrevWrap selects the previous item (wraps to end).
-func (l *SimpleList) SelectPrevWrap() tea.Cmd {
- if len(l.items) == 0 {
- return nil
- }
- prevIdx := (l.selectedIdx - 1 + len(l.items)) % len(l.items)
- return l.SetSelected(prevIdx)
-}
-
-// SelectFirstInView selects the first fully visible item.
-func (l *SimpleList) SelectFirstInView() tea.Cmd {
- if len(l.items) == 0 {
- return nil
- }
-
- start, end := l.viewPosition()
-
- for i := 0; i < len(l.items); i++ {
- if idItem, ok := l.items[i].(interface{ ID() string }); ok {
- if rItem, ok := l.renderedItems[idItem.ID()]; ok {
- // Check if fully visible.
- if rItem.start >= start && rItem.end <= end {
- return l.SetSelected(i)
- }
- }
- }
- }
-
- return nil
-}
-
-// SelectLastInView selects the last fully visible item.
-func (l *SimpleList) SelectLastInView() tea.Cmd {
- if len(l.items) == 0 {
- return nil
- }
-
- start, end := l.viewPosition()
-
- for i := len(l.items) - 1; i >= 0; i-- {
- if idItem, ok := l.items[i].(interface{ ID() string }); ok {
- if rItem, ok := l.renderedItems[idItem.ID()]; ok {
- // Check if fully visible.
- if rItem.start >= start && rItem.end <= end {
- return l.SetSelected(i)
- }
- }
- }
- }
-
- return nil
-}
-
-// SelectedItemInView returns true if the selected item is visible.
-func (l *SimpleList) SelectedItemInView() bool {
- if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
- return false
- }
-
- var rItem *renderedItem
- if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
- if ri, ok := l.renderedItems[idItem.ID()]; ok {
- rItem = &ri
- }
- }
-
- if rItem == nil {
- return false
- }
-
- start, end := l.viewPosition()
- return rItem.start < end && rItem.end > start
-}
-
-// Scrolling.
-
-// Offset returns the current scroll offset.
-func (l *SimpleList) Offset() int {
- return l.offset
-}
-
-// TotalHeight returns the total height of all items.
-func (l *SimpleList) TotalHeight() int {
- return l.renderedHeight
-}
-
-// ScrollBy scrolls by the given number of lines.
-func (l *SimpleList) ScrollBy(deltaLines int) tea.Cmd {
- l.offset += deltaLines
- l.clampOffset()
- return nil
-}
-
-// ScrollToTop scrolls to the top.
-func (l *SimpleList) ScrollToTop() tea.Cmd {
- l.offset = 0
- return nil
-}
-
-// ScrollToBottom scrolls to the bottom.
-func (l *SimpleList) ScrollToBottom() tea.Cmd {
- l.offset = l.renderedHeight - l.height
- l.clampOffset()
- return nil
-}
-
-// AtTop returns true if scrolled to the top.
-func (l *SimpleList) AtTop() bool {
- return l.offset <= 0
-}
-
-// AtBottom returns true if scrolled to the bottom.
-func (l *SimpleList) AtBottom() bool {
- return l.offset >= l.renderedHeight-l.height
-}
-
-// ScrollToItem scrolls to make an item visible.
-func (l *SimpleList) ScrollToItem(idx int) tea.Cmd {
- if idx < 0 || idx >= len(l.items) {
- return nil
- }
-
- var rItem *renderedItem
- if idItem, ok := l.items[idx].(interface{ ID() string }); ok {
- if ri, ok := l.renderedItems[idItem.ID()]; ok {
- rItem = &ri
- }
- }
-
- if rItem == nil {
- return nil
- }
-
- start, end := l.viewPosition()
-
- // Already visible.
- if rItem.start >= start && rItem.end <= end {
- return nil
- }
-
- // Above viewport.
- if rItem.start < start {
- l.offset = rItem.start
- return nil
- }
-
- // Below viewport.
- if rItem.end > end {
- l.offset = rItem.end - l.height + 1
- l.clampOffset()
- }
-
- return nil
-}
-
-// ScrollToSelected scrolls to the selected item.
-func (l *SimpleList) ScrollToSelected() tea.Cmd {
- if l.selectedIdx >= 0 {
- return l.ScrollToItem(l.selectedIdx)
- }
- return nil
-}
-
-func (l *SimpleList) clampOffset() {
- maxOffset := l.renderedHeight - l.height
- if maxOffset < 0 {
- maxOffset = 0
- }
- l.offset = ordered.Clamp(l.offset, 0, maxOffset)
-}
-
-// Mouse and highlighting.
-
-// HandleMouseDown handles mouse press.
-func (l *SimpleList) HandleMouseDown(x, y int) bool {
- if x < 0 || y < 0 || x >= l.width || y >= l.height {
- return false
- }
-
- // Find item at viewport y.
- contentY := l.offset + y
- itemIdx := l.findItemAtLine(contentY)
-
- if itemIdx < 0 {
- return false
- }
-
- l.mouseDown = true
- l.mouseDownItem = itemIdx
- l.mouseDownX = x
- l.mouseDownY = y
- l.mouseDragItem = itemIdx
- l.mouseDragX = x
- l.mouseDragY = y
-
- // Start selection.
- l.selectionStartLine = y
- l.selectionStartCol = x
- l.selectionEndLine = y
- l.selectionEndCol = x
-
- // Select item.
- l.SetSelected(itemIdx)
-
- return true
-}
-
-// HandleMouseDrag handles mouse drag.
-func (l *SimpleList) HandleMouseDrag(x, y int) bool {
- if !l.mouseDown {
- return false
- }
-
- // Clamp coordinates to viewport bounds.
- clampedX := max(0, min(x, l.width-1))
- clampedY := max(0, min(y, l.height-1))
-
- if clampedY >= 0 && clampedY < l.height {
- contentY := l.offset + clampedY
- itemIdx := l.findItemAtLine(contentY)
- if itemIdx >= 0 {
- l.mouseDragItem = itemIdx
- l.mouseDragX = clampedX
- l.mouseDragY = clampedY
- }
- }
-
- // Update selection end (clamped to viewport).
- l.selectionEndLine = clampedY
- l.selectionEndCol = clampedX
-
- return true
-}
-
-// HandleMouseUp handles mouse release.
-func (l *SimpleList) HandleMouseUp(x, y int) bool {
- if !l.mouseDown {
- return false
- }
-
- l.mouseDown = false
-
- // Final selection update (clamped to viewport).
- clampedX := max(0, min(x, l.width-1))
- clampedY := max(0, min(y, l.height-1))
- l.selectionEndLine = clampedY
- l.selectionEndCol = clampedX
-
- return true
-}
-
-// ClearHighlight clears the selection.
-func (l *SimpleList) ClearHighlight() {
- l.selectionStartLine = -1
- l.selectionStartCol = -1
- l.selectionEndLine = -1
- l.selectionEndCol = -1
- l.mouseDown = false
- l.mouseDownItem = -1
- l.mouseDragItem = -1
-}
-
-// GetHighlightedText returns the selected text.
-func (l *SimpleList) GetHighlightedText() string {
- if !l.hasSelection() {
- return ""
- }
-
- return l.renderSelection(l.View())
-}
-
-func (l *SimpleList) hasSelection() bool {
- return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
-}
-
-// renderSelection applies highlighting to the view and extracts text.
-func (l *SimpleList) renderSelection(view string) string {
- // Create a screen buffer spanning the viewport.
- buf := uv.NewScreenBuffer(l.width, l.height)
- area := uv.Rect(0, 0, l.width, l.height)
- uv.NewStyledString(view).Draw(&buf, area)
-
- // Calculate selection bounds.
- startLine := min(l.selectionStartLine, l.selectionEndLine)
- endLine := max(l.selectionStartLine, l.selectionEndLine)
- startCol := l.selectionStartCol
- endCol := l.selectionEndCol
-
- if l.selectionEndLine < l.selectionStartLine {
- startCol = l.selectionEndCol
- endCol = l.selectionStartCol
- }
-
- // Apply highlighting.
- for y := startLine; y <= endLine && y < l.height; y++ {
- if y >= buf.Height() {
- break
- }
-
- line := buf.Line(y)
-
- // Determine column range for this line.
- colStart := 0
- if y == startLine {
- colStart = startCol
- }
-
- colEnd := len(line)
- if y == endLine {
- colEnd = min(endCol, len(line))
- }
-
- // Apply highlight style.
- for x := colStart; x < colEnd && x < len(line); x++ {
- cell := line.At(x)
- if cell != nil && !cell.IsZero() {
- cell = cell.Clone()
- // Toggle reverse for highlight.
- if cell.Style.Attrs&uv.AttrReverse != 0 {
- cell.Style.Attrs &^= uv.AttrReverse
- } else {
- cell.Style.Attrs |= uv.AttrReverse
- }
- buf.SetCell(x, y, cell)
- }
- }
- }
-
- return buf.Render()
-}
-
-// findItemAtLine finds the item index at the given content line.
-func (l *SimpleList) findItemAtLine(line int) int {
- for i := 0; i < len(l.items); i++ {
- if idItem, ok := l.items[i].(interface{ ID() string }); ok {
- if rItem, ok := l.renderedItems[idItem.ID()]; ok {
- if line >= rItem.start && line <= rItem.end {
- return i
- }
- }
- }
- }
- return -1
-}
-
-// Render returns the view (for compatibility).
-func (l *SimpleList) Render() string {
- return l.View()
-}
@@ -2,14 +2,11 @@ package model
import (
"fmt"
- "image"
- "log/slog"
"path/filepath"
"strings"
"time"
"charm.land/lipgloss/v2"
- uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/crush/internal/config"
@@ -111,8 +108,6 @@ func (m *MessageContentItem) Render(width int) string {
// ToolCallItem represents a rendered tool call with its header and content.
type ToolCallItem struct {
- BaseFocusable
- BaseHighlightable
id string
toolCall message.ToolCall
toolResult message.ToolResult
@@ -139,7 +134,6 @@ func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.To
maxWidth: 120,
sty: sty,
}
- t.InitHighlight()
return t
}
@@ -156,18 +150,12 @@ func (t *ToolCallItem) ID() string {
// FocusStyle returns the focus style.
func (t *ToolCallItem) FocusStyle() lipgloss.Style {
- if t.focusStyle != nil {
- return *t.focusStyle
- }
- return lipgloss.Style{}
+ return t.sty.Chat.Message.ToolCallFocused
}
// BlurStyle returns the blur style.
func (t *ToolCallItem) BlurStyle() lipgloss.Style {
- if t.blurStyle != nil {
- return *t.blurStyle
- }
- return lipgloss.Style{}
+ return t.sty.Chat.Message.ToolCallBlurred
}
// HighlightStyle returns the highlight style.
@@ -189,24 +177,10 @@ func (t *ToolCallItem) Render(width int) string {
rendered := toolrender.Render(ctx)
return rendered
-
- // return t.RenderWithHighlight(rendered, width, style)
-}
-
-// SetHighlight implements list.Highlightable.
-func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
- t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
-}
-
-// UpdateResult updates the tool result and invalidates the cache if needed.
-func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
- t.toolResult = result
}
// AttachmentItem represents a file attachment in a user message.
type AttachmentItem struct {
- BaseFocusable
- BaseHighlightable
id string
filename string
path string
@@ -221,7 +195,6 @@ func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *Attachmen
path: path,
sty: sty,
}
- a.InitHighlight()
return a
}
@@ -232,18 +205,12 @@ func (a *AttachmentItem) ID() string {
// FocusStyle returns the focus style.
func (a *AttachmentItem) FocusStyle() lipgloss.Style {
- if a.focusStyle != nil {
- return *a.focusStyle
- }
- return lipgloss.Style{}
+ return a.sty.Chat.Message.AssistantFocused
}
// BlurStyle returns the blur style.
func (a *AttachmentItem) BlurStyle() lipgloss.Style {
- if a.blurStyle != nil {
- return *a.blurStyle
- }
- return lipgloss.Style{}
+ return a.sty.Chat.Message.AssistantBlurred
}
// HighlightStyle returns the highlight style.
@@ -410,16 +377,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
return items
}
- // Create base styles for the message
- var focusStyle, blurStyle lipgloss.Style
- if msg.Role == message.User {
- focusStyle = sty.Chat.Message.UserFocused
- blurStyle = sty.Chat.Message.UserBlurred
- } else {
- focusStyle = sty.Chat.Message.AssistantFocused
- blurStyle = sty.Chat.Message.AssistantBlurred
- }
-
// Process user messages
if msg.Role == message.User {
// Add main text content
@@ -444,8 +401,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
attachment.Path,
sty,
)
- item.SetHighlightStyle(ToStyler(sty.TextSelection))
- item.SetFocusStyles(&focusStyle, &blurStyle)
items = append(items, item)
}
@@ -566,11 +521,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
sty,
)
- item.SetHighlightStyle(ToStyler(sty.TextSelection))
-
- // Tool calls use muted style with optional focus border
- item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
-
items = append(items, item)
}
@@ -596,299 +546,3 @@ func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResu
}
return resultMap
}
-
-// BaseFocusable provides common focus state and styling for items.
-// Embed this type to add focus behavior to any item.
-type BaseFocusable struct {
- focused bool
- focusStyle *lipgloss.Style
- blurStyle *lipgloss.Style
-}
-
-// Focus implements Focusable interface.
-func (b *BaseFocusable) Focus(width int, content string) string {
- if b.focusStyle != nil {
- return b.focusStyle.Render(content)
- }
- return content
-}
-
-// Blur implements Focusable interface.
-func (b *BaseFocusable) Blur(width int, content string) string {
- if b.blurStyle != nil {
- return b.blurStyle.Render(content)
- }
- return content
-}
-
-// Focus implements Focusable interface.
-// func (b *BaseFocusable) Focus() {
-// b.focused = true
-// }
-
-// Blur implements Focusable interface.
-// func (b *BaseFocusable) Blur() {
-// b.focused = false
-// }
-
-// Focused implements Focusable interface.
-func (b *BaseFocusable) Focused() bool {
- return b.focused
-}
-
-// HasFocusStyles returns true if both focus and blur styles are configured.
-func (b *BaseFocusable) HasFocusStyles() bool {
- return b.focusStyle != nil && b.blurStyle != nil
-}
-
-// CurrentStyle returns the current style based on focus state.
-// Returns nil if no styles are configured, or if the current state's style is nil.
-func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
- if b.focused {
- return b.focusStyle
- }
- return b.blurStyle
-}
-
-// SetFocusStyles sets the focus and blur styles.
-func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
- b.focusStyle = focusStyle
- b.blurStyle = blurStyle
-}
-
-// CellStyler defines a function that styles a [uv.Style].
-type CellStyler func(uv.Style) uv.Style
-
-// BaseHighlightable provides common highlight state for items.
-// Embed this type to add highlight behavior to any item.
-type BaseHighlightable struct {
- highlightStartLine int
- highlightStartCol int
- highlightEndLine int
- highlightEndCol int
- highlightStyle CellStyler
-}
-
-// SetHighlight implements Highlightable interface.
-func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) {
- b.highlightStartLine = startLine
- b.highlightStartCol = startCol
- b.highlightEndLine = endLine
- b.highlightEndCol = endCol
-}
-
-// GetHighlight implements Highlightable interface.
-func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) {
- return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol
-}
-
-// HasHighlight returns true if a highlight region is set.
-func (b *BaseHighlightable) HasHighlight() bool {
- return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 ||
- b.highlightEndLine >= 0 || b.highlightEndCol >= 0
-}
-
-// SetHighlightStyle sets the style function used for highlighting.
-func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
- b.highlightStyle = style
-}
-
-// GetHighlightStyle returns the current highlight style function.
-func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
- return b.highlightStyle
-}
-
-// InitHighlight initializes the highlight fields with default values.
-func (b *BaseHighlightable) InitHighlight() {
- b.highlightStartLine = -1
- b.highlightStartCol = -1
- b.highlightEndLine = -1
- b.highlightEndCol = -1
- b.highlightStyle = ToStyler(lipgloss.NewStyle().Reverse(true))
-}
-
-// Highlight implements Highlightable interface.
-func (b *BaseHighlightable) Highlight(width int, content string, startLine, startCol, endLine, endCol int) string {
- b.SetHighlight(startLine, startCol, endLine, endCol)
- return b.RenderWithHighlight(content, width, nil)
-}
-
-// RenderWithHighlight renders content with optional focus styling and highlighting.
-// This is a helper that combines common rendering logic for all items.
-// The content parameter should be the raw rendered content before focus styling.
-// The style parameter should come from CurrentStyle() and may be nil.
-func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string {
- // Apply focus/blur styling if configured
- rendered := content
- if style != nil {
- rendered = style.Render(rendered)
- }
-
- if !b.HasHighlight() {
- return rendered
- }
-
- height := lipgloss.Height(rendered)
-
- // Create temp buffer to draw content with highlighting
- tempBuf := uv.NewScreenBuffer(width, height)
-
- // Draw the rendered content to temp buffer
- styled := uv.NewStyledString(rendered)
- styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
-
- // Apply highlighting if active
- b.ApplyHighlight(&tempBuf, width, height, style)
-
- return tempBuf.Render()
-}
-
-// ApplyHighlight applies highlighting to a screen buffer.
-// This should be called after drawing content to the buffer.
-func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
- if b.highlightStartLine < 0 {
- return
- }
-
- var (
- topMargin, topBorder, topPadding int
- rightMargin, rightBorder, rightPadding int
- bottomMargin, bottomBorder, bottomPadding int
- leftMargin, leftBorder, leftPadding int
- )
- if style != nil {
- topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
- topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
- style.GetBorderRightSize(),
- style.GetBorderBottomSize(),
- style.GetBorderLeftSize()
- topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
- }
-
- slog.Info("Applying highlight",
- "highlightStartLine", b.highlightStartLine,
- "highlightStartCol", b.highlightStartCol,
- "highlightEndLine", b.highlightEndLine,
- "highlightEndCol", b.highlightEndCol,
- "width", width,
- "height", height,
- "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin),
- "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder),
- "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding),
- )
-
- // Calculate content area offsets
- contentArea := image.Rectangle{
- Min: image.Point{
- X: leftMargin + leftBorder + leftPadding,
- Y: topMargin + topBorder + topPadding,
- },
- Max: image.Point{
- X: width - (rightMargin + rightBorder + rightPadding),
- Y: height - (bottomMargin + bottomBorder + bottomPadding),
- },
- }
-
- for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
- if y >= buf.Height() {
- break
- }
-
- line := buf.Line(y)
-
- // Determine column range for this line
- startCol := 0
- if y == b.highlightStartLine {
- startCol = min(b.highlightStartCol, len(line))
- }
-
- endCol := len(line)
- if y == b.highlightEndLine {
- endCol = min(b.highlightEndCol, len(line))
- }
-
- // Track last non-empty position as we go
- lastContentX := -1
-
- // Single pass: check content and track last non-empty position
- for x := startCol; x < endCol; x++ {
- cell := line.At(x)
- if cell == nil {
- continue
- }
-
- // Update last content position if non-empty
- if cell.Content != "" && cell.Content != " " {
- lastContentX = x
- }
- }
-
- // Only apply highlight up to last content position
- highlightEnd := endCol
- if lastContentX >= 0 {
- highlightEnd = lastContentX + 1
- } else if lastContentX == -1 {
- highlightEnd = startCol // No content on this line
- }
-
- // Apply highlight style only to cells with content
- for x := startCol; x < highlightEnd; x++ {
- if !image.Pt(x, y).In(contentArea) {
- continue
- }
- cell := line.At(x)
- cell.Style = b.highlightStyle(cell.Style)
- }
- }
-}
-
-// ToStyler converts a [lipgloss.Style] to a [CellStyler].
-func ToStyler(lgStyle lipgloss.Style) CellStyler {
- return func(uv.Style) uv.Style {
- return ToStyle(lgStyle)
- }
-}
-
-// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
-func ToStyle(lgStyle lipgloss.Style) uv.Style {
- var uvStyle uv.Style
-
- // Colors are already color.Color
- uvStyle.Fg = lgStyle.GetForeground()
- uvStyle.Bg = lgStyle.GetBackground()
-
- // Build attributes using bitwise OR
- var attrs uint8
-
- if lgStyle.GetBold() {
- attrs |= uv.AttrBold
- }
-
- if lgStyle.GetItalic() {
- attrs |= uv.AttrItalic
- }
-
- if lgStyle.GetUnderline() {
- uvStyle.Underline = uv.UnderlineSingle
- }
-
- if lgStyle.GetStrikethrough() {
- attrs |= uv.AttrStrikethrough
- }
-
- if lgStyle.GetFaint() {
- attrs |= uv.AttrFaint
- }
-
- if lgStyle.GetBlink() {
- attrs |= uv.AttrBlink
- }
-
- if lgStyle.GetReverse() {
- attrs |= uv.AttrReverse
- }
-
- uvStyle.Attrs = attrs
-
- return uvStyle
-}