init layout, notifications, tabs & more

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-08-17 22:09:32 +02:00
parent f3a1877832
commit 5f99fa7521
68 changed files with 6375 additions and 330 deletions
+72
View File
@@ -0,0 +1,72 @@
package style
import (
"strings"
"charm.land/lipgloss/v2"
)
// borderTypes maps the `border:` config value to a lipgloss.Border. Only the
// symmetric, general-purpose border kinds are exposed here; MarkdownBorder,
// BlockBorder and the half-block variants are content-specific rather than a
// theming choice.
var borderTypes = map[string]lipgloss.Border{
"rounded": lipgloss.RoundedBorder(),
"normal": lipgloss.NormalBorder(),
"thick": lipgloss.ThickBorder(),
"double": lipgloss.DoubleBorder(),
"hidden": lipgloss.HiddenBorder(),
"ascii": lipgloss.ASCIIBorder(),
}
// resolveBorderType maps a `border:` config value to a lipgloss.Border,
// falling back to RoundedBorder for an empty or unrecognized name.
func resolveBorderType(name string) lipgloss.Border {
if b, ok := borderTypes[strings.ToLower(strings.TrimSpace(name))]; ok {
return b
}
return lipgloss.RoundedBorder()
}
// ContentHeight returns the usable inner height for a bordered panel of totalH rows.
func ContentHeight(totalH int) int {
h := totalH - 2
if h < 0 {
return 0
}
return h
}
// RenderWithTitle renders a bordered box with a title embedded in the top border.
// title may contain ANSI color codes. width and height are the total outer dimensions.
//
// Example:
//
// box := style.RenderWithTitle(style.S.PanelFocused, "Header", content, w, h)
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
boxH := height - 1
if contentH := boxH - 1; contentH > 0 {
lines := strings.Split(content, "\n")
if len(lines) > contentH {
content = strings.Join(lines[:contentH], "\n")
}
}
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
titleW := lipgloss.Width(title)
// Pull the corner/fill glyphs from the style's own border spec instead of
// hardcoding rounded-border characters, so this respects style.S.BorderType.
b, _, _, _, _ := border.GetBorder()
topLeft, top, topRight := b.TopLeft, b.Top, b.TopRight
fillW := boxWidth - titleW - lipgloss.Width(topLeft) - lipgloss.Width(topRight) - 2 // 2 = the spaces around the title
if fillW < 0 {
fillW = 0
}
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
topLine := bc.Render(topLeft+" ") + bc.Render(title) + bc.Render(" "+strings.Repeat(top, fillW)+topRight)
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
}
+64
View File
@@ -0,0 +1,64 @@
package style
type colorsYAML struct {
Base00 string `yaml:"base00"`
Base01 string `yaml:"base01"`
Base02 string `yaml:"base02"`
Base03 string `yaml:"base03"`
Base04 string `yaml:"base04"`
Base05 string `yaml:"base05"`
Base06 string `yaml:"base06"`
Base07 string `yaml:"base07"`
Base08 string `yaml:"base08"`
Base09 string `yaml:"base09"`
Base0A string `yaml:"base0a"`
Base0B string `yaml:"base0b"`
Base0C string `yaml:"base0c"`
Base0D string `yaml:"base0d"`
Base0E string `yaml:"base0e"`
Base0F string `yaml:"base0f"`
}
type configYAML struct {
Colors colorsYAML `yaml:"colors"`
NerdFonts bool `yaml:"nerd_fonts"`
Border string `yaml:"border"`
}
func pickString(base, user string) string {
if user != "" {
return user
}
return base
}
func mergeConfig(base, user configYAML) configYAML {
return configYAML{
Colors: mergeColors(base.Colors, user.Colors),
// The embedded default is always nerd_fonts: false, so this just
// reduces to "whatever the user set".
NerdFonts: base.NerdFonts || user.NerdFonts,
Border: pickString(base.Border, user.Border),
}
}
func mergeColors(base, user colorsYAML) colorsYAML {
return colorsYAML{
Base00: pickString(base.Base00, user.Base00),
Base01: pickString(base.Base01, user.Base01),
Base02: pickString(base.Base02, user.Base02),
Base03: pickString(base.Base03, user.Base03),
Base04: pickString(base.Base04, user.Base04),
Base05: pickString(base.Base05, user.Base05),
Base06: pickString(base.Base06, user.Base06),
Base07: pickString(base.Base07, user.Base07),
Base08: pickString(base.Base08, user.Base08),
Base09: pickString(base.Base09, user.Base09),
Base0A: pickString(base.Base0A, user.Base0A),
Base0B: pickString(base.Base0B, user.Base0B),
Base0C: pickString(base.Base0C, user.Base0C),
Base0D: pickString(base.Base0D, user.Base0D),
Base0E: pickString(base.Base0E, user.Base0E),
Base0F: pickString(base.Base0F, user.Base0F),
}
}
+28
View File
@@ -0,0 +1,28 @@
# ilovetui default config
# Copy to ~/.config/ilovetui/config.yaml and edit to customize.
# Whether components may use Nerd Font glyphs (requires a patched font).
# Leave false for maximum terminal/font compatibility.
nerd_fonts: false
# Border style used by panels across all ilovetui-based TUIs.
# One of: rounded, normal, thick, double, hidden, ascii.
border: rounded
colors:
base00: "#110F12" # Background
base01: "#1C1920" # Lighter Background / Status Bars
base02: "#1D1A26" # Selection Background
base03: "#514D63" # Comments / Invisibles
base04: "#8E8AA0" # Dark Foreground / Status Bars
base05: "#C2BED6" # Default Foreground
base06: "#D8D5EA" # Light Foreground
base07: "#EAE7F7" # Light Background
base08: "#E07080" # Variables / Errors / Diff Deleted
base09: "#D49070" # Integers / Constants / Booleans
base0a: "#C4B060" # Classes / Warnings / Search Background
base0b: "#80B880" # Strings / Success / Diff Inserted
base0c: "#70B8C0" # Support / Regex / Escape Characters
base0d: "#9E97F8" # Functions / Methods / Headings / Accent
base0e: "#C090E8" # Keywords / Storage / Diff Changed
base0f: "#D080A0" # Embedded / Misc
+242
View File
@@ -0,0 +1,242 @@
package style
import (
"fmt"
"image/color"
"charm.land/glamour/v2/ansi"
)
func hexColor(c color.Color) *string {
r, g, b, _ := c.RGBA()
s := fmt.Sprintf("#%02X%02X%02X", r>>8, g>>8, b>>8)
return &s
}
// GlamourStyleConfig returns a glamour ansi.StyleConfig using the active theme.
func GlamourStyleConfig() ansi.StyleConfig {
str := func(s string) *string { return &s }
boolPtr := func(b bool) *bool { return &b }
uintPtr := func(u uint) *uint { return &u }
hex := hexColor
return ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
BlockSuffix: "\n",
Color: hex(S.Base05),
},
Margin: uintPtr(2),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: hex(S.Base03),
Italic: boolPtr(true),
},
Indent: uintPtr(1),
IndentToken: str("│ "),
},
List: ansi.StyleList{
LevelIndent: 2,
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: hex(S.Base0D),
Bold: boolPtr(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: hex(S.Base07),
BackgroundColor: hex(S.Base0D),
Bold: boolPtr(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: hex(S.Base0D),
Bold: boolPtr(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: hex(S.Base0C),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: hex(S.Base0B),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: hex(S.Base09),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: hex(S.Base08),
Bold: boolPtr(false),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: boolPtr(true),
},
Emph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
Strong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
HorizontalRule: ansi.StylePrimitive{
Color: hex(S.Base03),
Format: "\n--------\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
},
Task: ansi.StyleTask{
Ticked: "[✓] ",
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: hex(S.Base0C),
Underline: boolPtr(true),
},
LinkText: ansi.StylePrimitive{
Color: hex(S.Base0D),
Bold: boolPtr(true),
},
Image: ansi.StylePrimitive{
Color: hex(S.Base0C),
Underline: boolPtr(true),
},
ImageText: ansi.StylePrimitive{
Color: hex(S.Base04),
Format: "Image: {{.text}} ->",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: hex(S.Base0B),
BackgroundColor: hex(S.Base01),
},
},
CodeBlock: ansi.StyleCodeBlock{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: hex(S.Base04),
},
Margin: uintPtr(2),
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{
Color: hex(S.Base05),
},
Error: ansi.StylePrimitive{
Color: hex(S.Base07),
BackgroundColor: hex(S.Base08),
},
Comment: ansi.StylePrimitive{
Color: hex(S.Base03),
Italic: boolPtr(true),
},
CommentPreproc: ansi.StylePrimitive{
Color: hex(S.Base09),
},
Keyword: ansi.StylePrimitive{
Color: hex(S.Base0E),
},
KeywordReserved: ansi.StylePrimitive{
Color: hex(S.Base0E),
},
KeywordNamespace: ansi.StylePrimitive{
Color: hex(S.Base0D),
},
KeywordType: ansi.StylePrimitive{
Color: hex(S.Base0A),
},
Operator: ansi.StylePrimitive{
Color: hex(S.Base05),
},
Punctuation: ansi.StylePrimitive{
Color: hex(S.Base05),
},
Name: ansi.StylePrimitive{
Color: hex(S.Base05),
},
NameBuiltin: ansi.StylePrimitive{
Color: hex(S.Base0D),
},
NameTag: ansi.StylePrimitive{
Color: hex(S.Base08),
},
NameAttribute: ansi.StylePrimitive{
Color: hex(S.Base0A),
},
NameClass: ansi.StylePrimitive{
Color: hex(S.Base0A),
Bold: boolPtr(true),
Underline: boolPtr(true),
},
NameConstant: ansi.StylePrimitive{
Color: hex(S.Base09),
},
NameDecorator: ansi.StylePrimitive{
Color: hex(S.Base0C),
},
NameFunction: ansi.StylePrimitive{
Color: hex(S.Base0D),
},
LiteralNumber: ansi.StylePrimitive{
Color: hex(S.Base09),
},
LiteralString: ansi.StylePrimitive{
Color: hex(S.Base0B),
},
LiteralStringEscape: ansi.StylePrimitive{
Color: hex(S.Base0C),
},
GenericDeleted: ansi.StylePrimitive{
Color: hex(S.Base08),
},
GenericEmph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
GenericInserted: ansi.StylePrimitive{
Color: hex(S.Base0B),
},
GenericStrong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
GenericSubheading: ansi.StylePrimitive{
Color: hex(S.Base04),
},
Background: ansi.StylePrimitive{
BackgroundColor: hex(S.Base01),
},
},
},
Table: ansi.StyleTable{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{},
},
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n> ",
},
}
}
+122
View File
@@ -0,0 +1,122 @@
package style
import (
"image/color"
"strings"
"charm.land/lipgloss/v2"
)
// Styles holds both the raw Base16 palette and ready-to-use semantic colors
// and lipgloss styles. Access via the package-level variable S.
type Styles struct {
// Raw Base16 palette
Base00 color.Color
Base01 color.Color
Base02 color.Color
Base03 color.Color
Base04 color.Color
Base05 color.Color
Base06 color.Color
Base07 color.Color
Base08 color.Color
Base09 color.Color
Base0A color.Color
Base0B color.Color
Base0C color.Color
Base0D color.Color
Base0E color.Color
Base0F color.Color
// Semantic color aliases
Background color.Color
SubtleBg color.Color
Selection color.Color
Subtle color.Color
Muted color.Color
Text color.Color
Primary color.Color
Success color.Color
Warning color.Color
Error color.Color
// Pre-built text styles
Bold lipgloss.Style
Faint lipgloss.Style
// User preferences, read from config
NerdFonts bool
BorderType lipgloss.Border
// Pre-built panel styles, bordered with BorderType
Panel lipgloss.Style
PanelFocused lipgloss.Style
// Pre-rendered pager dot strings
PagerDotActive string
PagerDotInactive string
}
func newStyles(c colorsYAML, nerdFonts bool, borderName string) Styles {
lc := func(s string) color.Color {
s = strings.TrimSpace(s)
if s != "" && s[0] != '#' {
s = "#" + s
}
return lipgloss.Color(s)
}
b00 := lc(c.Base00)
b01 := lc(c.Base01)
b02 := lc(c.Base02)
b03 := lc(c.Base03)
b04 := lc(c.Base04)
b05 := lc(c.Base05)
b06 := lc(c.Base06)
b07 := lc(c.Base07)
b08 := lc(c.Base08)
b09 := lc(c.Base09)
b0A := lc(c.Base0A)
b0B := lc(c.Base0B)
b0C := lc(c.Base0C)
b0D := lc(c.Base0D)
b0E := lc(c.Base0E)
b0F := lc(c.Base0F)
borderType := resolveBorderType(borderName)
return Styles{
Base00: b00, Base01: b01, Base02: b02, Base03: b03,
Base04: b04, Base05: b05, Base06: b06, Base07: b07,
Base08: b08, Base09: b09, Base0A: b0A, Base0B: b0B,
Base0C: b0C, Base0D: b0D, Base0E: b0E, Base0F: b0F,
Background: b00,
SubtleBg: b01,
Selection: b02,
Subtle: b03,
Muted: b04,
Text: b05,
Primary: b0D,
Success: b0B,
Warning: b09,
Error: b08,
Bold: lipgloss.NewStyle().Bold(true),
Faint: lipgloss.NewStyle().Foreground(b03).Faint(true),
NerdFonts: nerdFonts,
BorderType: borderType,
Panel: lipgloss.NewStyle().
Border(borderType).
BorderForeground(b03),
PanelFocused: lipgloss.NewStyle().
Border(borderType).
BorderForeground(b0D),
PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(b03).SetString("•").String(),
}
}
+117
View File
@@ -0,0 +1,117 @@
// Package style provides a shared Base16 color theme for bubbletea/lipgloss
// applications. The theme is loaded automatically on import from
// ~/.config/ilovetui/config.yaml (falling back to the embedded
// default config). Access colors and styles via the package-level variable S.
//
// import "github.com/anotherhadi/ilovetui/style"
//
// s := lipgloss.NewStyle().Foreground(style.S.Primary)
// box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
package style
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
//go:embed default.yaml
var DefaultConfig []byte
// S is the active theme. It is populated automatically at import time and can
// be reloaded at any point by calling Init, InitFrom, or InitFromBytes.
var S Styles
func init() {
path := DefaultConfigPath()
if data, err := os.ReadFile(path); err == nil {
if s, err := stylesFromBytes(data); err == nil {
S = s
return
}
}
// Silent fallback: embedded default always works.
s, _ := stylesFromBytes(DefaultConfig)
S = s
}
// Init reloads S from the user config file, falling back to the embedded
// default if the file is missing. Returns an error only on parse failures.
func Init() error {
path := DefaultConfigPath()
data, err := os.ReadFile(path)
if err != nil {
s, e := stylesFromBytes(DefaultConfig)
if e != nil {
return e
}
S = s
return nil
}
return InitFromBytes(data)
}
// InitFrom reloads S from an explicit file path.
func InitFrom(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("style: read config: %w", err)
}
return InitFromBytes(data)
}
// InitFromBytes reloads S from raw YAML. Accepts hex strings with or without
// the leading '#'.
func InitFromBytes(data []byte) error {
s, err := stylesFromBytes(data)
if err != nil {
return err
}
S = s
return nil
}
// WriteDefaultConfig writes the embedded default config to path, creating
// parent directories as needed. No-op if the file already exists.
func WriteDefaultConfig(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("style: create config dir: %w", err)
}
if err := os.WriteFile(path, DefaultConfig, 0o600); err != nil {
return fmt.Errorf("style: write config: %w", err)
}
return nil
}
// DefaultConfigPath returns the canonical user config path,
// respecting $XDG_CONFIG_HOME.
func DefaultConfigPath() string {
return filepath.Join(configDir(), "ilovetui", "config.yaml")
}
func stylesFromBytes(data []byte) (Styles, error) {
var base configYAML
if err := yaml.Unmarshal(DefaultConfig, &base); err != nil {
return Styles{}, fmt.Errorf("style: parse default config: %w", err)
}
var user configYAML
if err := yaml.Unmarshal(data, &user); err != nil {
return Styles{}, fmt.Errorf("style: parse config: %w", err)
}
merged := mergeConfig(base, user)
return newStyles(merged.Colors, merged.NerdFonts, merged.Border), nil
}
func configDir() string {
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config")
}