commit 3da2f5898ce6c38fc136fd8fa02c6a305368583f Author: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Tue May 26 14:44:03 2026 +0200 init Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/.goreleaser.yaml b/.github/.goreleaser.yaml new file mode 100644 index 0000000..13a3fd4 --- /dev/null +++ b/.github/.goreleaser.yaml @@ -0,0 +1,21 @@ +version: 2 + +before: + hooks: + - go mod tidy + +archives: + - formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..7b8ae86 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +Everybody is invited and welcome to contribute. There is a lot to do... Check the issues! + +The process is straight-forward. + +- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1) +- Fork this git repository +- Write your changes (bug fixes, new features, ...). +- Create a Pull Request against the main branch. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b1c5749 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: anotherhadi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bf265c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean --config .github/.goreleaser.yaml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03ab393 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.claude/ +CLAUDE.md +result/ +.pre-commit-config.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ce7478 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hadi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ae2bfa --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# Ilovetui + +A minimal Go library that provides a shared [Base16](https://github.com/tinted-theming/home) color theme for terminal UIs built with [bubbletea](https://github.com/charmbracelet/bubbletea) and [lipgloss](https://github.com/charmbracelet/lipgloss). + +The idea is simple: instead of every TUI app managing its own colors, they all share one theme file so the user customizes once and every app looks consistent. + +## How it works + +On import, `ilovetui` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (respecting `$XDG_CONFIG_HOME`). +If no config exists, it falls back to the embedded default. The active theme is exposed as the package-level variable `S`. + +```go +import "github.com/anotherhadi/ilovetui" + +// Use colors directly +style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) + +// Use pre-built panel styles +box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h) +``` + +No setup required — just import and use. + +## Installation + +```sh +go get github.com/anotherhadi/ilovetui +``` + +## Theme + +The theme follows the [Base16](https://github.com/tinted-theming/home) standard (16 colors). The library exposes both the raw palette and semantic aliases: + +| Alias | Base16 | Meaning | +| ------------ | ------ | --------------------------------------- | +| `Background` | Base00 | Background | +| `SubtleBg` | Base01 | Lighter Background / Status Bars | +| `Selection` | Base02 | Selection Background | +| `Subtle` | Base03 | Comments / Invisibles | +| `Muted` | Base04 | Dark Foreground / Status Bars | +| `Text` | Base05 | Default Foreground | +| `Primary` | Base0D | Functions / Methods / Headings / Accent | +| `Success` | Base0B | Strings / Success / Diff Inserted | +| `Warning` | Base09 | Integers / Constants / Booleans | +| `Error` | Base08 | Variables / Errors / Diff Deleted | + +The default theme is `./default.yaml`. Copy it and edit to customize: + +```sh +mkdir -p ~/.config/ilovetui +cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/default.yaml ~/.config/ilovetui/config.yaml +``` + +Or let your app write it on first run: + +```go +ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath()) +``` + +## Pre-built styles + +`S` ships with a few ready-to-use lipgloss styles: + +| Field | Description | +| ---------------- | ---------------------------------------- | +| `S.Bold` | Bold text | +| `S.Faint` | Muted / dimmed text | +| `S.Panel` | Rounded border, unfocused (Subtle color) | +| `S.PanelFocused` | Rounded border, focused (Primary color) | + +## Helpers + +```go +// Inner usable height of a bordered panel with outer height h +inner := ilovetui.ContentHeight(h) + +// Render a box with a title embedded in the top border +box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Header", content, w, h) +``` + +## API + +```go +ilovetui.Init() // Reload from default config path +ilovetui.InitFrom(path string) // Reload from a custom path +ilovetui.InitFromBytes(data []byte) // Parse raw YAML +ilovetui.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml +ilovetui.WriteDefaultConfig(path) // Write default config if missing +``` + +## Projects using ilovetui + +- [anotherhadi/spilltea](https://github.com/anotherhadi/spilltea): A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players. Think Burp Suite or Caido, but entirely in your terminal. +- [anotherhadi/usbguard-tui](https://github.com/anotherhadi/usbguard-tui): A terminal UI for managing USB devices via usbguard. TUI built with golang & bubbletea. +- [anotherhadi/jwt-tui](https://github.com/anotherhadi/jwt-tui): A terminal UI for inspecting, editing, and signing JSON Web Tokens (JWTs). diff --git a/border.go b/border.go new file mode 100644 index 0000000..4b48f7d --- /dev/null +++ b/border.go @@ -0,0 +1,44 @@ +package ilovetui + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// 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 := ilovetui.RenderWithTitle(theme.Styles.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) + fillW := boxWidth - titleW - 4 // 4 = "╭ " + " " + "╮" + if fillW < 0 { + fillW = 0 + } + bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()) + topLine := bc.Render("╭ ") + title + bc.Render(" "+strings.Repeat("─", fillW)+"╮") + + return lipgloss.JoinVertical(lipgloss.Left, topLine, box) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..c20905a --- /dev/null +++ b/config.go @@ -0,0 +1,51 @@ +package ilovetui + +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"` +} + +func mergeColors(base, user colorsYAML) colorsYAML { + pick := func(b, u string) string { + if u != "" { + return u + } + return b + } + return colorsYAML{ + Base00: pick(base.Base00, user.Base00), + Base01: pick(base.Base01, user.Base01), + Base02: pick(base.Base02, user.Base02), + Base03: pick(base.Base03, user.Base03), + Base04: pick(base.Base04, user.Base04), + Base05: pick(base.Base05, user.Base05), + Base06: pick(base.Base06, user.Base06), + Base07: pick(base.Base07, user.Base07), + Base08: pick(base.Base08, user.Base08), + Base09: pick(base.Base09, user.Base09), + Base0A: pick(base.Base0A, user.Base0A), + Base0B: pick(base.Base0B, user.Base0B), + Base0C: pick(base.Base0C, user.Base0C), + Base0D: pick(base.Base0D, user.Base0D), + Base0E: pick(base.Base0E, user.Base0E), + Base0F: pick(base.Base0F, user.Base0F), + } +} diff --git a/default.yaml b/default.yaml new file mode 100644 index 0000000..bcf2903 --- /dev/null +++ b/default.yaml @@ -0,0 +1,19 @@ +# ilovetui default theme +# Copy to ~/.config/ilovetui/config.yaml and edit to customize. +colors: + base00: "#1e1e2e" # Background + base01: "#181825" # Lighter Background / Status Bars + base02: "#313244" # Selection Background + base03: "#45475a" # Comments / Invisibles + base04: "#585b70" # Dark Foreground / Status Bars + base05: "#cdd6f4" # Default Foreground + base06: "#f5f5f5" # Light Foreground + base07: "#b4befe" # Light Background + base08: "#f38ba8" # Variables / Errors / Diff Deleted + base09: "#fab387" # Integers / Constants / Booleans + base0a: "#f9e2af" # Classes / Warnings / Search Background + base0b: "#a6e3a1" # Strings / Success / Diff Inserted + base0c: "#94e2d5" # Support / Regex / Escape Characters + base0d: "#89b4fa" # Functions / Methods / Headings / Accent + base0e: "#cba6f7" # Keywords / Storage / Diff Changed + base0f: "#f2cdcd" # Embedded / Misc diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a96ad5c --- /dev/null +++ b/flake.lock @@ -0,0 +1,87 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778507602, + "narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1779560665, + "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..bd99aa1 --- /dev/null +++ b/flake.nix @@ -0,0 +1,30 @@ +{ + description = ""; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + git-hooks = { + url = "github:cachix/git-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { + self, + nixpkgs, + git-hooks, + }: let + supportedSystems = ["x86_64-linux" "aarch64-linux"]; + + forAllSystems = f: + nixpkgs.lib.genAttrs supportedSystems + (system: f system (import nixpkgs {inherit system;})); + in { + devShells = forAllSystems (system: pkgs: { + default = import ./nix/shell.nix { + inherit pkgs; + gitHooksLib = git-hooks.lib.${system}; + }; + }); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e4e9689 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/anotherhadi/ilovetui + +go 1.25.0 + +require ( + charm.land/lipgloss/v2 v2.0.3 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..44ef17d --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ilovetui.go b/ilovetui.go new file mode 100644 index 0000000..39d309e --- /dev/null +++ b/ilovetui.go @@ -0,0 +1,116 @@ +// Package ilovetui 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 := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) +// box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h) +package ilovetui + +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("ilovetui: 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("ilovetui: create config dir: %w", err) + } + if err := os.WriteFile(path, DefaultConfig, 0o600); err != nil { + return fmt.Errorf("ilovetui: 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("ilovetui: parse default config: %w", err) + } + var user configYAML + if err := yaml.Unmarshal(data, &user); err != nil { + return Styles{}, fmt.Errorf("ilovetui: parse config: %w", err) + } + return newStyles(mergeColors(base.Colors, user.Colors)), nil +} + +func configDir() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return dir + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 0000000..f195e2f --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,22 @@ +{ + pkgs, + gitHooksLib, +}: let + hooks = gitHooksLib.run { + src = ../.; + hooks = { + gofmt.enable = true; + govet.enable = true; + }; + }; +in + pkgs.mkShell { + packages = with pkgs; + [ + go + doctoc + ] + ++ hooks.enabledPackages; + + shellHook = hooks.shellHook; + } diff --git a/style.go b/style.go new file mode 100644 index 0000000..967b33b --- /dev/null +++ b/style.go @@ -0,0 +1,106 @@ +package ilovetui + +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 + + // Pre-built panel styles (rounded border) + Panel lipgloss.Style + PanelFocused lipgloss.Style +} + +func newStyles(c colorsYAML) 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) + + 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), + + Panel: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(b03), + + PanelFocused: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(b0D), + } +}