mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
28d2de8f39
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.
321 lines
8.5 KiB
Go
321 lines
8.5 KiB
Go
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
|
||
}
|