mirror of
https://github.com/chenasraf/watchr.git
synced 2026-05-18 01:29:05 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c46bfa7c | ||
| 5e1b807105 | |||
| 51b9e92ddd | |||
| 320874a61a | |||
| cdd895d19a |
4
.editorconfig
Normal file
4
.editorconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.0](https://github.com/chenasraf/watchr/compare/v1.2.0...v1.3.0) (2025-12-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add exit status to command header ([51b9e92](https://github.com/chenasraf/watchr/commit/51b9e92ddd9766d109429878cc706761e1f46e47))
|
||||
* help overlay window ([cdd895d](https://github.com/chenasraf/watchr/commit/cdd895d19ac78b3bc34ccde7da9f2136b6661abd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* current line style ([5e1b807](https://github.com/chenasraf/watchr/commit/5e1b8071054c9727a5a8c3db85c88e1ede8e27d0))
|
||||
|
||||
## [1.2.0](https://github.com/chenasraf/watchr/compare/v1.1.0...v1.2.0) (2025-12-03)
|
||||
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -22,6 +22,10 @@ install: build
|
||||
uninstall:
|
||||
rm -f ~/.local/bin/$(BIN)
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
.PHONY: precommit-install
|
||||
precommit-install:
|
||||
@echo "Installing pre-commit hooks..."
|
||||
|
||||
@@ -177,6 +177,7 @@ Configuration values are applied in this order (later sources override earlier o
|
||||
| `/` | Enter filter mode |
|
||||
| `Esc` | Exit filter mode / clear filter |
|
||||
| `y` | Yank (copy) selected line |
|
||||
| `?` | Show help overlay |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -38,22 +38,28 @@ func NewRunner(shell, command string) *Runner {
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the command and returns output lines
|
||||
func (r *Runner) Run(ctx context.Context) ([]Line, error) {
|
||||
// Result contains the output and exit code of a command run
|
||||
type Result struct {
|
||||
Lines []Line
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// Run executes the command and returns output lines with exit code
|
||||
func (r *Runner) Run(ctx context.Context) (Result, error) {
|
||||
cmd := exec.CommandContext(ctx, r.Shell, "-c", r.Command)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start command: %w", err)
|
||||
return Result{}, fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
var lines []Line
|
||||
@@ -79,10 +85,15 @@ func (r *Runner) Run(ctx context.Context) ([]Line, error) {
|
||||
lineNum++
|
||||
}
|
||||
|
||||
// Wait for command to finish (ignore exit code - we still want to show output)
|
||||
_ = cmd.Wait()
|
||||
// Wait for command to finish and get exit code
|
||||
exitCode := 0
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
}
|
||||
}
|
||||
|
||||
return lines, nil
|
||||
return Result{Lines: lines, ExitCode: exitCode}, nil
|
||||
}
|
||||
|
||||
// RunStreaming executes the command and streams output lines to the callback
|
||||
|
||||
@@ -52,17 +52,17 @@ func TestRunner_Run(t *testing.T) {
|
||||
r := NewRunner(tt.shell, tt.command)
|
||||
ctx := context.Background()
|
||||
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(lines) != tt.wantLines {
|
||||
t.Errorf("expected %d lines, got %d", tt.wantLines, len(lines))
|
||||
if len(result.Lines) != tt.wantLines {
|
||||
t.Errorf("expected %d lines, got %d", tt.wantLines, len(result.Lines))
|
||||
}
|
||||
|
||||
if tt.wantLines > 0 && lines[0].Content != tt.wantContent {
|
||||
t.Errorf("expected first line %q, got %q", tt.wantContent, lines[0].Content)
|
||||
if tt.wantLines > 0 && result.Lines[0].Content != tt.wantContent {
|
||||
t.Errorf("expected first line %q, got %q", tt.wantContent, result.Lines[0].Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -103,13 +103,17 @@ func TestRunner_RunWithFailingCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Should not return error for non-zero exit, just empty output
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(lines) != 0 {
|
||||
t.Errorf("expected 0 lines for exit 1, got %d", len(lines))
|
||||
if len(result.Lines) != 0 {
|
||||
t.Errorf("expected 0 lines for exit 1, got %d", len(result.Lines))
|
||||
}
|
||||
|
||||
if result.ExitCode != 1 {
|
||||
t.Errorf("expected exit code 1, got %d", result.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,17 +121,21 @@ func TestRunner_RunWithOutputAndError(t *testing.T) {
|
||||
r := NewRunner("sh", "echo 'output'; exit 1")
|
||||
ctx := context.Background()
|
||||
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected 1 line, got %d", len(lines))
|
||||
if len(result.Lines) != 1 {
|
||||
t.Fatalf("expected 1 line, got %d", len(result.Lines))
|
||||
}
|
||||
|
||||
if lines[0].Content != "output" {
|
||||
t.Errorf("expected 'output', got %q", lines[0].Content)
|
||||
if result.Lines[0].Content != "output" {
|
||||
t.Errorf("expected 'output', got %q", result.Lines[0].Content)
|
||||
}
|
||||
|
||||
if result.ExitCode != 1 {
|
||||
t.Errorf("expected exit code 1, got %d", result.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ type model struct {
|
||||
filter string
|
||||
filterMode bool
|
||||
showPreview bool
|
||||
showHelp bool // help overlay visible
|
||||
width int
|
||||
height int
|
||||
runner *runner.Runner
|
||||
@@ -54,10 +55,14 @@ type model struct {
|
||||
loading bool
|
||||
errorMsg string
|
||||
statusMsg string // temporary status message (e.g., "Yanked!")
|
||||
exitCode int // last command exit code
|
||||
}
|
||||
|
||||
// messages
|
||||
type linesMsg []runner.Line
|
||||
type resultMsg struct {
|
||||
lines []runner.Line
|
||||
exitCode int
|
||||
}
|
||||
type errMsg struct{ err error }
|
||||
type tickMsg time.Time
|
||||
type clearStatusMsg struct{}
|
||||
@@ -114,11 +119,11 @@ func (m model) runCommand() tea.Cmd {
|
||||
r := m.runner
|
||||
ctx := m.ctx
|
||||
return func() tea.Msg {
|
||||
lines, err := r.Run(ctx)
|
||||
result, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return linesMsg(lines)
|
||||
return resultMsg{lines: result.Lines, exitCode: result.ExitCode}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,8 +137,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
|
||||
case linesMsg:
|
||||
m.lines = []runner.Line(msg)
|
||||
case resultMsg:
|
||||
m.lines = msg.lines
|
||||
m.exitCode = msg.exitCode
|
||||
m.loading = false
|
||||
m.updateFiltered()
|
||||
return m, nil
|
||||
@@ -167,6 +173,15 @@ func (m model) tickCmd() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// In help mode, any key closes it
|
||||
if m.showHelp {
|
||||
switch msg.String() {
|
||||
case "?", "esc", "q", "enter":
|
||||
m.showHelp = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// In filter mode, handle text input
|
||||
if m.filterMode {
|
||||
switch msg.Type {
|
||||
@@ -228,6 +243,8 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "/":
|
||||
m.filterMode = true
|
||||
m.filter = ""
|
||||
case "?":
|
||||
m.showHelp = true
|
||||
case "y":
|
||||
// Yank (copy) selected line to clipboard
|
||||
if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) {
|
||||
@@ -298,8 +315,8 @@ func (m model) previewSize() int {
|
||||
}
|
||||
|
||||
func (m model) visibleLines() int {
|
||||
// Fixed lines: top border (1) + header (2) + separator (1) + bottom border (1) + prompt (1) = 6
|
||||
fixedLines := 6
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
if m.showPreview && (m.config.PreviewPosition == PreviewTop || m.config.PreviewPosition == PreviewBottom) {
|
||||
// Add preview height + separator between content and preview
|
||||
return m.height - fixedLines - m.previewSize() - 1
|
||||
@@ -392,11 +409,261 @@ func (m *model) updateFiltered() {
|
||||
m.offset = 0
|
||||
}
|
||||
|
||||
// renderHelpOverlay creates the help box content (without positioning)
|
||||
func (m model) renderHelpOverlay() (box string, boxWidth, boxHeight int) {
|
||||
keyStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("10")) // green
|
||||
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252")) // light gray
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("12")) // blue
|
||||
|
||||
// Define keybindings
|
||||
bindings := []struct {
|
||||
key string
|
||||
desc string
|
||||
}{
|
||||
{"j / k", "Move down / up"},
|
||||
{"g / G", "Go to first / last line"},
|
||||
{"Ctrl+d / Ctrl+u", "Half page down / up"},
|
||||
{"PgDn / PgUp", "Full page down / up"},
|
||||
{"Ctrl+f / Ctrl+b", "Full page down / up"},
|
||||
{"", ""},
|
||||
{"p", "Toggle preview pane"},
|
||||
{"/", "Enter filter mode"},
|
||||
{"Esc", "Exit filter / clear"},
|
||||
{"", ""},
|
||||
{"r / Ctrl+r", "Reload command"},
|
||||
{"y", "Copy line to clipboard"},
|
||||
{"q / Esc", "Quit"},
|
||||
{"?", "Toggle this help"},
|
||||
}
|
||||
|
||||
// Build content
|
||||
var content strings.Builder
|
||||
content.WriteString(titleStyle.Render("Keybindings"))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
for _, b := range bindings {
|
||||
if b.key == "" {
|
||||
content.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
key := keyStyle.Render(fmt.Sprintf("%-18s", b.key))
|
||||
desc := descStyle.Render(b.desc)
|
||||
content.WriteString(fmt.Sprintf(" %s %s\n", key, desc))
|
||||
}
|
||||
|
||||
content.WriteString("\n")
|
||||
content.WriteString(descStyle.Render("Press any key to close"))
|
||||
|
||||
// Create box style
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("12")).
|
||||
Padding(1, 2)
|
||||
|
||||
box = boxStyle.Render(content.String())
|
||||
boxWidth = lipgloss.Width(box)
|
||||
boxHeight = lipgloss.Height(box)
|
||||
|
||||
return box, boxWidth, boxHeight
|
||||
}
|
||||
|
||||
// splitAtVisualWidth splits a string at a visual width position, handling ANSI codes
|
||||
// Returns (left part, right part) where left has exactly targetWidth visual width
|
||||
func splitAtVisualWidth(s string, targetWidth int) (string, string) {
|
||||
var left, right strings.Builder
|
||||
visualWidth := 0
|
||||
inEscape := false
|
||||
runes := []rune(s)
|
||||
|
||||
i := 0
|
||||
// Build left part up to targetWidth
|
||||
for i < len(runes) && visualWidth < targetWidth {
|
||||
r := runes[i]
|
||||
|
||||
if r == '\x1b' {
|
||||
// Start of ANSI escape sequence - include it in left part
|
||||
left.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
left.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
left.WriteRune(runes[i]) // terminator
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
if visualWidth+runeWidth <= targetWidth {
|
||||
left.WriteRune(r)
|
||||
visualWidth += runeWidth
|
||||
i++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pad left if needed
|
||||
for visualWidth < targetWidth {
|
||||
left.WriteRune(' ')
|
||||
visualWidth++
|
||||
}
|
||||
|
||||
// Skip runes in the "overlay zone" - we don't need them for right part calculation
|
||||
// The caller will handle inserting the overlay content
|
||||
|
||||
// Build right part from remaining
|
||||
for ; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
if r == '\x1b' {
|
||||
right.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
right.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
right.WriteRune(runes[i])
|
||||
}
|
||||
continue
|
||||
}
|
||||
right.WriteRune(r)
|
||||
}
|
||||
|
||||
_ = inEscape // unused but kept for clarity
|
||||
return left.String(), right.String()
|
||||
}
|
||||
|
||||
// skipVisualWidth skips a number of visual width units in a string, handling ANSI codes
|
||||
// It preserves and returns ANSI sequences encountered during skipping so styling can be restored
|
||||
func skipVisualWidth(s string, skipWidth int) string {
|
||||
var result strings.Builder
|
||||
var ansiState strings.Builder // collect ANSI codes while skipping
|
||||
visualWidth := 0
|
||||
runes := []rune(s)
|
||||
|
||||
i := 0
|
||||
// Skip until we've passed skipWidth, but collect ANSI codes
|
||||
for i < len(runes) && visualWidth < skipWidth {
|
||||
r := runes[i]
|
||||
|
||||
if r == '\x1b' {
|
||||
// ANSI escape - collect it (don't count visual width)
|
||||
ansiState.WriteRune(r)
|
||||
i++
|
||||
for i < len(runes) && !isAnsiTerminator(runes[i]) {
|
||||
ansiState.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
if i < len(runes) {
|
||||
ansiState.WriteRune(runes[i]) // terminator
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
runeWidth := lipgloss.Width(string(r))
|
||||
visualWidth += runeWidth
|
||||
i++
|
||||
}
|
||||
|
||||
// Prepend collected ANSI state to restore styling
|
||||
result.WriteString(ansiState.String())
|
||||
|
||||
// Output the rest
|
||||
for ; i < len(runes); i++ {
|
||||
result.WriteRune(runes[i])
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func isAnsiTerminator(r rune) bool {
|
||||
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')
|
||||
}
|
||||
|
||||
// overlayBox composites an overlay box on top of a base view
|
||||
func overlayBox(base string, box string, boxWidth, boxHeight, screenWidth, screenHeight int) string {
|
||||
// ANSI reset sequence to stop any styling from bleeding into overlay
|
||||
const ansiReset = "\x1b[0m"
|
||||
|
||||
// Split base into lines
|
||||
baseLines := strings.Split(base, "\n")
|
||||
|
||||
// Ensure we have enough lines
|
||||
for len(baseLines) < screenHeight {
|
||||
baseLines = append(baseLines, "")
|
||||
}
|
||||
|
||||
// Split box into lines
|
||||
boxLines := strings.Split(box, "\n")
|
||||
|
||||
// Calculate center position
|
||||
startX := (screenWidth - boxWidth) / 2
|
||||
startY := (screenHeight - boxHeight) / 2
|
||||
|
||||
if startX < 0 {
|
||||
startX = 0
|
||||
}
|
||||
if startY < 0 {
|
||||
startY = 0
|
||||
}
|
||||
|
||||
// Overlay box onto base
|
||||
for i, boxLine := range boxLines {
|
||||
y := startY + i
|
||||
if y >= len(baseLines) {
|
||||
break
|
||||
}
|
||||
|
||||
baseLine := baseLines[y]
|
||||
baseVisualWidth := lipgloss.Width(baseLine)
|
||||
|
||||
// Get left part (before overlay)
|
||||
leftPart, _ := splitAtVisualWidth(baseLine, startX)
|
||||
|
||||
// Get right part (after overlay)
|
||||
endX := startX + boxWidth
|
||||
var rightPart string
|
||||
if endX < baseVisualWidth {
|
||||
rightPart = skipVisualWidth(baseLine, endX)
|
||||
}
|
||||
|
||||
// Combine: left + reset + box + right
|
||||
// Reset before overlay to stop highlight bleeding into overlay
|
||||
baseLines[y] = leftPart + ansiReset + boxLine + rightPart
|
||||
}
|
||||
|
||||
return strings.Join(baseLines, "\n")
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
// Render the main UI
|
||||
mainView := m.renderMainView()
|
||||
|
||||
// Overlay help if active
|
||||
if m.showHelp {
|
||||
box, boxWidth, boxHeight := m.renderHelpOverlay()
|
||||
return overlayBox(mainView, box, boxWidth, boxHeight, m.width, m.height)
|
||||
}
|
||||
|
||||
return mainView
|
||||
}
|
||||
|
||||
func (m model) renderMainView() string {
|
||||
// Box drawing characters (rounded)
|
||||
const (
|
||||
topLeft = "╭"
|
||||
@@ -415,16 +682,12 @@ func (m model) View() string {
|
||||
borderStyle := lipgloss.NewStyle().Foreground(borderColor)
|
||||
|
||||
// Styles
|
||||
headerTextStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("12"))
|
||||
|
||||
promptStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("14"))
|
||||
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("236")).
|
||||
Foreground(lipgloss.Color("15")).
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Bold(true)
|
||||
|
||||
lineNumStyle := lipgloss.NewStyle().
|
||||
@@ -472,9 +735,23 @@ func (m model) View() string {
|
||||
return borderStyle.Render(vertical) + content + borderStyle.Render(vertical)
|
||||
}
|
||||
|
||||
// Build header content
|
||||
header := headerTextStyle.Render("r reload • q quit • j/k move • g/G first/last • ^d/u/f/b scroll • p preview • / filter • y yank")
|
||||
commandLine := fmt.Sprintf("Command: %s", m.config.Command)
|
||||
// Build header content with status indicator
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) // blue
|
||||
prefix := titleStyle.Render("watchr") + " • "
|
||||
|
||||
var commandLine string
|
||||
if m.loading {
|
||||
// Still loading - no status yet
|
||||
commandLine = prefix + m.config.Command
|
||||
} else if m.exitCode == 0 {
|
||||
// Success - green checkmark and green command
|
||||
successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green
|
||||
commandLine = prefix + successStyle.Render("✓ "+m.config.Command)
|
||||
} else {
|
||||
// Failure - red cross with exit code and red command
|
||||
failStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // red
|
||||
commandLine = prefix + failStyle.Render(fmt.Sprintf("✗ [%d] %s", m.exitCode, m.config.Command))
|
||||
}
|
||||
|
||||
// Build prompt line (will go at bottom)
|
||||
var promptLine string
|
||||
@@ -493,6 +770,15 @@ func (m model) View() string {
|
||||
promptLine += " " + statusStyle.Render(m.statusMsg)
|
||||
}
|
||||
|
||||
// Add help hint on the right
|
||||
helpHint := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render("? for help")
|
||||
promptWidth := lipgloss.Width(promptLine)
|
||||
hintWidth := lipgloss.Width(helpHint)
|
||||
gap := m.width - promptWidth - hintWidth
|
||||
if gap > 0 {
|
||||
promptLine += strings.Repeat(" ", gap) + helpHint
|
||||
}
|
||||
|
||||
// Calculate layout
|
||||
listHeight := m.visibleLines()
|
||||
// listWidth is content area minus 1 for padding before border
|
||||
@@ -522,6 +808,11 @@ func (m model) View() string {
|
||||
line := m.lines[idx]
|
||||
|
||||
var lineText string
|
||||
isSelected := lineIdx == m.cursor
|
||||
|
||||
// Full width including the padding space before border
|
||||
fullWidth := listWidth + 1
|
||||
|
||||
if m.config.ShowLineNums {
|
||||
// Calculate widths without ANSI codes first
|
||||
lineNumStr := fmt.Sprintf("%*d ", m.config.LineNumWidth, line.Number)
|
||||
@@ -531,20 +822,39 @@ func (m model) View() string {
|
||||
// Truncate content (no ANSI codes yet)
|
||||
content := truncateToWidth(line.Content, contentWidth)
|
||||
|
||||
// Now apply styling
|
||||
lineText = lineNumStyle.Render(lineNumStr) + content
|
||||
if isSelected {
|
||||
// For selected line: gray line number + black content, both on white background
|
||||
selectedLineNumStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("241"))
|
||||
selectedContentStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("15")).
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Bold(true)
|
||||
|
||||
// Pad content to fill remaining width
|
||||
contentPadded := content
|
||||
padding := fullWidth - lineNumWidth - lipgloss.Width(content)
|
||||
if padding > 0 {
|
||||
contentPadded = content + strings.Repeat(" ", padding)
|
||||
}
|
||||
lineText = selectedLineNumStyle.Render(lineNumStr) + selectedContentStyle.Render(contentPadded)
|
||||
} else {
|
||||
// Normal line - style line numbers differently
|
||||
lineText = lineNumStyle.Render(lineNumStr) + content
|
||||
}
|
||||
} else {
|
||||
// No line numbers, just truncate content
|
||||
lineText = truncateToWidth(line.Content, listWidth)
|
||||
}
|
||||
|
||||
if lineIdx == m.cursor {
|
||||
// Pad to full width for selection highlight
|
||||
padding := listWidth - lipgloss.Width(lineText)
|
||||
if padding > 0 {
|
||||
lineText = lineText + strings.Repeat(" ", padding)
|
||||
if isSelected {
|
||||
// Pad to full width for selection highlight
|
||||
padding := fullWidth - lipgloss.Width(lineText)
|
||||
if padding > 0 {
|
||||
lineText = lineText + strings.Repeat(" ", padding)
|
||||
}
|
||||
lineText = selectedStyle.Render(lineText)
|
||||
}
|
||||
lineText = selectedStyle.Render(lineText)
|
||||
}
|
||||
|
||||
listLines = append(listLines, lineText)
|
||||
@@ -583,8 +893,7 @@ func (m model) View() string {
|
||||
// Top border (no junction - vertical split starts at header separator)
|
||||
lines = append(lines, hLine(topLeft, topRight, 0))
|
||||
|
||||
// Header lines
|
||||
lines = append(lines, padLine(header))
|
||||
// Header line (command only)
|
||||
lines = append(lines, padLine(commandLine))
|
||||
|
||||
// Separator between header and content (T junction if vertical split)
|
||||
|
||||
@@ -252,8 +252,8 @@ func TestVisibleLines(t *testing.T) {
|
||||
m := initialModel(cfg)
|
||||
m.height = 100
|
||||
|
||||
// Fixed lines: top border (1) + header (2) + separator (1) + bottom border (1) + prompt (1) = 6
|
||||
fixedLines := 6
|
||||
// Fixed lines: top border (1) + header (1) + separator (1) + bottom border (1) + prompt (1) = 5
|
||||
fixedLines := 5
|
||||
|
||||
// Without preview
|
||||
m.showPreview = false
|
||||
|
||||
1
main.go
1
main.go
@@ -55,6 +55,7 @@ func main() {
|
||||
_, _ = fmt.Fprintf(w, " / Enter filter mode\n")
|
||||
_, _ = fmt.Fprintf(w, " Esc Exit filter mode / clear filter\n")
|
||||
_, _ = fmt.Fprintf(w, " y Yank (copy) selected line\n")
|
||||
_, _ = fmt.Fprintf(w, " ? Show help overlay\n")
|
||||
}
|
||||
|
||||
flag.Usage = func() {
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.2.0
|
||||
1.3.0
|
||||
|
||||
Reference in New Issue
Block a user