Files
kit/internal/ui/overlay.go
T
Ed Zynda 28d2de8f39 Phase 1: Reorganize UI leaf utilities into subpackages
Moved leaf utility files to subpackages for better organization:
- events.go -> core/ (core message types)
- clipboard.go -> clipboard/ (clipboard operations)
- commands.go -> commands/ (slash commands)
- file_processor.go -> fileutil/ (file attachment processing)
- preferences.go -> prefs/ (theme/model preferences)
- enhanced_styles.go, styles.go, themes.go -> style/ (theming system)

Added exports.go to re-export commonly used types for backward
compatibility. External importers can still use ui.XXX without
changes.

All tests pass, basic smoke test successful.
2026-04-01 13:54:10 +03:00

321 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/ui/style"
)
// ---------------------------------------------------------------------------
// Overlay dialog — modal overlay rendered by AppModel when active
// ---------------------------------------------------------------------------
// overlayResult carries the synchronous outcome of an overlay dialog update.
// A non-nil value means the overlay is done (completed or cancelled); nil
// means the overlay is still active.
type overlayResult struct {
completed bool
cancelled bool
action string
index int
}
// overlayDialog holds the state of an active modal overlay dialog. It is
// created when an OverlayRequestEvent arrives and destroyed when the user
// completes or cancels. The AppModel owns the overlay and routes messages
// to it while in stateOverlay.
type overlayDialog struct {
title string
content string
markdown bool
borderColor string
background string
actions []string
selAction int // selected action index
scrollOff int // scroll offset for content body
totalLines int // total body lines (computed on render)
width int // terminal width
height int // terminal height
dialogWidth int // configured dialog width (0 = auto)
maxHeight int // configured max height (0 = auto)
anchor string
}
// newOverlayDialog creates an overlay dialog from an OverlayRequestEvent's
// parameters.
func newOverlayDialog(title, content string, markdown bool, borderColor, background string, width, maxHeight int, anchor string, actions []string, termWidth, termHeight int) *overlayDialog {
return &overlayDialog{
title: title,
content: content,
markdown: markdown,
borderColor: borderColor,
background: background,
actions: actions,
dialogWidth: width,
maxHeight: maxHeight,
anchor: anchor,
width: termWidth,
height: termHeight,
}
}
// Init returns the initial command for the overlay. Currently no-op.
func (o *overlayDialog) Init() tea.Cmd {
return nil
}
// Update handles messages for the overlay dialog. It returns a non-nil
// *overlayResult when the user completes or cancels. The returned tea.Cmd
// is always nil (overlays don't produce async commands).
func (o *overlayDialog) Update(msg tea.Msg) (*overlayResult, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
o.width = msg.Width
o.height = msg.Height
return nil, nil
case tea.KeyPressMsg:
return o.handleKey(msg)
}
return nil, nil
}
func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd) {
switch msg.String() {
case "esc":
return &overlayResult{cancelled: true}, nil
case "enter":
if len(o.actions) > 0 {
action := ""
if o.selAction < len(o.actions) {
action = o.actions[o.selAction]
}
return &overlayResult{completed: true, action: action, index: o.selAction}, nil
}
// No actions — Enter dismisses (not cancelled).
return &overlayResult{completed: true, action: "", index: -1}, nil
// Content scrolling
case "up", "k":
if o.scrollOff > 0 {
o.scrollOff--
}
case "down", "j":
// Clamped in Render; allow incrementing freely.
o.scrollOff++
case "home", "g":
o.scrollOff = 0
case "end", "G":
// Set to a large value; Render will clamp.
o.scrollOff = o.totalLines
// Action navigation
case "left", "h":
if len(o.actions) > 0 && o.selAction > 0 {
o.selAction--
}
case "right", "l":
if len(o.actions) > 0 && o.selAction < len(o.actions)-1 {
o.selAction++
}
case "tab":
if len(o.actions) > 0 {
o.selAction = (o.selAction + 1) % len(o.actions)
}
}
return nil, nil
}
// Render returns the overlay dialog as a styled string for full-view
// composition. The dialog is a bordered box centered (or anchored)
// horizontally within the terminal width.
func (o *overlayDialog) Render() string {
theme := style.GetTheme()
// Calculate dialog dimensions, clamped to terminal bounds.
termW := max(o.width, 10)
termH := max(o.height, 5)
dw := o.dialogWidth
if dw == 0 {
dw = termW * 60 / 100
}
dw = clamp(dw, min(24, termW), termW-2)
mh := o.maxHeight
if mh == 0 {
mh = termH * 80 / 100
}
mh = clamp(mh, min(6, termH), termH)
// Inner width accounts for border (2) + horizontal padding (2 left + 1 right).
innerWidth := max(dw-5, 6)
// Render body text (potentially as markdown).
bodyText := o.content
if o.markdown {
bodyText = style.ToMarkdown(bodyText, innerWidth)
}
bodyText = strings.TrimRight(bodyText, "\n")
bodyLines := strings.Split(bodyText, "\n")
o.totalLines = len(bodyLines)
// Calculate available height for the scrollable body.
// Chrome: border(2) + padTop(1) + padBottom(1) + hintLine(1) = 5
chromeLines := 5
if o.title != "" {
chromeLines += 2 // title line + separator line
}
if len(o.actions) > 0 {
chromeLines += 2 // separator line + action bar
}
maxBodyLines := max(mh-chromeLines, 1)
scrollable := len(bodyLines) > maxBodyLines
if scrollable {
// Clamp scroll offset.
maxOff := len(bodyLines) - maxBodyLines
if o.scrollOff > maxOff {
o.scrollOff = maxOff
}
if o.scrollOff < 0 {
o.scrollOff = 0
}
bodyLines = bodyLines[o.scrollOff : o.scrollOff+maxBodyLines]
} else {
o.scrollOff = 0
}
// Build the content to render inside the border.
var parts []string
// Title + separator.
if o.title != "" {
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.Text)
parts = append(parts, titleStyle.Render(o.title))
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(repeatRune('─', innerWidth)))
}
// Body content.
parts = append(parts, "")
parts = append(parts, strings.Join(bodyLines, "\n"))
// Scroll indicator.
if scrollable {
indicator := fmt.Sprintf("(%d%d of %d lines)",
o.scrollOff+1,
min(o.scrollOff+maxBodyLines, o.totalLines),
o.totalLines)
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Render(indicator))
} else {
parts = append(parts, "")
}
// Action bar.
if len(o.actions) > 0 {
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(repeatRune('─', innerWidth)))
var actionParts []string
for i, a := range o.actions {
if i == o.selAction {
actionParts = append(actionParts,
lipgloss.NewStyle().Bold(true).Foreground(theme.Accent).Render("> "+a))
} else {
actionParts = append(actionParts,
lipgloss.NewStyle().Foreground(theme.Text).Render(" "+a))
}
}
parts = append(parts, strings.Join(actionParts, " "))
}
innerContent := strings.Join(parts, "\n")
// Resolve border color.
borderClr := theme.Info
if o.borderColor != "" {
borderClr = lipgloss.Color(o.borderColor)
}
// Build the dialog box style.
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(borderClr).
Width(dw-2). // -2 for border chars
Padding(1, 1, 1, 2).
Foreground(theme.Text)
if o.background != "" {
dialogStyle = dialogStyle.Background(lipgloss.Color(o.background))
}
dialog := dialogStyle.Render(innerContent)
// Key hints below the dialog, adapted to width.
var hints []string
if termW >= 50 {
if scrollable {
hints = append(hints, "↑/↓ scroll")
}
if len(o.actions) > 0 {
hints = append(hints, "←/→ switch")
hints = append(hints, "Enter select")
} else {
hints = append(hints, "Enter dismiss")
}
hints = append(hints, "Esc cancel")
} else {
if len(o.actions) > 0 {
hints = append(hints, "↵ select")
} else {
hints = append(hints, "↵ ok")
}
hints = append(hints, "esc")
}
hintText := lipgloss.NewStyle().
Foreground(theme.Muted).
Render(" " + strings.Join(hints, " "))
full := lipgloss.JoinVertical(lipgloss.Left, dialog, hintText)
// Center horizontally within the terminal width.
centered := lipgloss.PlaceHorizontal(o.width, lipgloss.Center, full)
// Apply vertical positioning based on anchor.
// Calculate how many lines we have and how many we need.
contentHeight := lipgloss.Height(centered)
if contentHeight < o.height {
switch o.anchor {
case "top-center":
// Add one blank line at top for breathing room.
centered = "\n" + centered
case "bottom-center":
// Pad from the top so the dialog sits near the bottom.
topPad := o.height - contentHeight - 1
if topPad > 0 {
centered = strings.Repeat("\n", topPad) + centered
}
default: // "center"
// Vertically center within available height.
topPad := (o.height - contentHeight) / 2
if topPad > 0 {
centered = strings.Repeat("\n", topPad) + centered
}
}
}
return centered
}