Merge pull request #73 from LazyBachelor/LPM-138

LPM-138 Refactor Tui with composable modals and use canvas

Refactors the TUI (dashboard + kanban) to use a shared, composable modal system with canvas-based overlay rendering, while consolidating styling into internal/style and expanding issue interactions (e.g., comments).

Changes:

Introduces a new pkg/tui/modal system (manager/stack + multiple modal types) and overlays modals using Lipgloss compositor layers.
Refactors dashboard/kanban views and input handling to use the modal manager + focus manager instead of per-modal boolean state.
Consolidates TUI styling by moving from pkg/tui/styles to internal/style and updates components to use the new style package; adds a shared footer renderer.
This commit is contained in:
Robin Olsen
2026-03-20 08:28:10 -07:00
committed by GitHub
parent 9a47acc49a
commit 7facfb6afe
27 changed files with 2469 additions and 1752 deletions

View File

@@ -2,30 +2,98 @@ package style
import (
"charm.land/lipgloss/v2"
)
// Color palette
var (
PrimaryColor = lipgloss.Color("6")
SecondaryColor = lipgloss.Color("2")
AccentColor = lipgloss.Color("7")
TextColor = lipgloss.Color("15")
BorderColor = lipgloss.Color("8")
"charm.land/lipgloss/v2/compat"
)
var (
AppStyle = lipgloss.NewStyle().Padding(1, 2).Foreground(TextColor)
Primary = compat.AdaptiveColor{Light: lipgloss.Color("#5A56E0"), Dark: lipgloss.Color("#7571F9")}
Secondary = compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")}
Success = compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")}
Warning = compat.AdaptiveColor{Light: lipgloss.Color("#F59E0B"), Dark: lipgloss.Color("#F59E0B")}
Error = compat.AdaptiveColor{Light: lipgloss.Color("#FE5F86"), Dark: lipgloss.Color("#FE5F86")}
PrimaryText = compat.AdaptiveColor{Light: lipgloss.Color("#1A1A1A"), Dark: lipgloss.Color("#E0E0E0")}
SecondaryText = compat.AdaptiveColor{Light: lipgloss.Color("#666666"), Dark: lipgloss.Color("#999999")}
FaintText = compat.AdaptiveColor{Light: lipgloss.Color("#999999"), Dark: lipgloss.Color("#666666")}
PrimaryBorder = compat.AdaptiveColor{Light: lipgloss.Color("#5A56E0"), Dark: lipgloss.Color("#7571F9")}
SecondaryBorder = compat.AdaptiveColor{Light: lipgloss.Color("#CCCCCC"), Dark: lipgloss.Color("#444444")}
SelectedBackground = compat.AdaptiveColor{Light: lipgloss.Color("#E8E8E8"), Dark: lipgloss.Color("#333333")}
)
const (
ListViewRatio = 52 // Percentage of total width allocated to the list view
LabelWidth = 14
MarginBottomSmall = 1
)
var DefaultBorder = lipgloss.ThickBorder()
var (
HeaderStyle = lipgloss.NewStyle().Foreground(Primary).Padding(0, 1).Bold(true)
HeaderTitleStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true).Padding(0)
)
var ContainerStyle = lipgloss.NewStyle().
Border(DefaultBorder, true, false, false, false).
BorderForeground(SecondaryBorder).
Padding(1)
var ModalContainerStyle = lipgloss.NewStyle().
Border(DefaultBorder).
BorderForeground(PrimaryBorder).
Padding(2, 3)
var DetailsContainerStyle = lipgloss.NewStyle().
Border(DefaultBorder, true, false, false, true).
BorderForeground(SecondaryBorder).
Padding(1)
var (
RowStyle = lipgloss.NewStyle().MarginBottom(MarginBottomSmall)
TitleStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true)
LabelStyle = lipgloss.NewStyle().Foreground(SecondaryText)
ValueStyle = lipgloss.NewStyle().Foreground(PrimaryText)
IssueTypeStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true)
)
var (
DefaultBorder = lipgloss.NormalBorder()
BorderStyle = lipgloss.NewStyle().Border(DefaultBorder).BorderForeground(BorderColor)
FilterStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true).Padding(0, 1)
FilterInputStyle = lipgloss.NewStyle().Foreground(PrimaryText).Padding(0, 1)
FilterPromptStyle = lipgloss.NewStyle().Foreground(Primary).Bold(true)
)
func StatusStyle(status string) lipgloss.Style {
style := lipgloss.NewStyle().Bold(true)
switch status {
case "open":
return style.Foreground(Secondary)
case "closed":
return style.Foreground(FaintText)
case "in_progress":
return style.Foreground(Warning)
case "blocked":
return style.Foreground(Error)
default:
return style.Foreground(SecondaryText)
}
}
func HighlightKey(key string) string {
return lipgloss.NewStyle().
Foreground(Primary).
Bold(true).
Padding(0, 1).
Render(key)
}
var (
TitleStyle = lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true)
TextStyle = lipgloss.NewStyle().Foreground(TextColor)
HelpStyle = lipgloss.NewStyle().Align(lipgloss.Center).Foreground(AccentColor)
ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) // Red color for errors
TextStyle = lipgloss.NewStyle().Foreground(PrimaryText)
BorderStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(SecondaryBorder)
ErrorStyle = lipgloss.NewStyle().Foreground(Error).Bold(true)
HelpStyle = lipgloss.NewStyle().Align(lipgloss.Center).Foreground(Secondary)
)
var SecondaryColor = lipgloss.Color("#02BA84")

View File

@@ -0,0 +1,43 @@
package truncate
import "charm.land/lipgloss/v2"
// TruncateToWidth trims the given text so that its rendered width does not
// exceed maxWidth. If truncation occurs and there is room, an ellipsis is
// appended to indicate that the text was shortened.
func TruncateToWidth(text string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
if lipgloss.Width(text) <= maxWidth {
return text
}
const ellipsis = "…"
ellipsisWidth := lipgloss.Width(ellipsis)
if ellipsisWidth > maxWidth {
runes := []rune(text)
if len(runes) > 0 {
firstChar := string(runes[0])
if lipgloss.Width(firstChar) <= maxWidth {
return firstChar
}
}
return ""
}
runes := []rune(text)
lastSafe := 0
for i := range runes {
candidate := string(runes[:i+1])
if lipgloss.Width(candidate)+ellipsisWidth > maxWidth {
break
}
lastSafe = i + 1
}
current := string(runes[:lastSafe])
return current + ellipsis
}

View File

@@ -0,0 +1,19 @@
package user
import (
"os"
"os/user"
)
func GetOsUsername() string {
if u, err := user.Current(); err == nil && u.Username != "" {
return u.Username
}
if s := os.Getenv("USER"); s != "" {
return s
}
if s := os.Getenv("USERNAME"); s != "" {
return s
}
return "user"
}