-- SPDX-FileCopyrightText: © 2022—2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. local std = require("std") local style = require("term.tss") local generation = 0 local rss_cache = {} -- Semantic palette: 26 roles that themes override to change the entire look. -- Style rules reference these roles; catalog themes only need to redefine this table. -- The built-in default is the Gruvbox Dark palette. local default_palette = { -- Background tiers bg_deep = { 29, 32, 33 }, -- deepest bg, code bg bg_mid = { 40, 40, 40 }, -- mid bg, widget items bg_surface = { 80, 73, 69 }, -- borders, dividers bg_raised = { 102, 92, 84 }, -- raised chrome, line numbers -- Foreground tiers (8-level gradient, dimmest to brightest) fg1 = { 112, 100, 90 }, -- disabled, faintest fg2 = { 124, 111, 100 }, -- dim secondary fg3 = { 146, 131, 116 }, -- subdued metadata fg4 = { 168, 153, 132 }, -- below-normal fg5 = { 189, 174, 147 }, -- normal/body fg6 = { 202, 186, 158 }, -- readable fg7 = { 213, 196, 161 }, -- strong labels fg8 = { 235, 219, 178 }, -- maximum emphasis -- Accent colors (4 shades) accent1 = { 142, 192, 124 }, -- primary highlight, chrome accent2 = { 104, 157, 106 }, -- secondary accent, content accent3 = { 131, 165, 152 }, -- tertiary, links, code elements accent4 = { 69, 133, 136 }, -- deep/strong accent -- Status colors success = { 184, 187, 38 }, -- success, enabled, staged error = { 251, 73, 52 }, -- error, remove, pipe warning = { 250, 189, 47 }, -- warning, dirty, emph caution = { 254, 128, 25 }, -- caution, tool names, info -- Auxiliary special1 = { 177, 98, 134 }, -- purple, decorative special2 = { 211, 134, 155 }, -- light purple, mode labels warm1 = { 215, 153, 33 }, -- amber, timestamps warm2 = { 152, 151, 26 }, -- cocoa/earth brown, separators warm3 = { 132, 140, 70 }, -- sage, organic metadata -- Canonical text color text = { 213, 196, 161 }, -- body text across all sections (== fg7) } -- Element overrides applied on top of the palette-derived defaults for the -- built-in (Gruvbox) theme — colors that don't derive from the palette alone. local default_overrides = { markdown = { heading = { fg = { 199, 158, 0 } }, strong = { fg = { 184, 134, 11 } }, code_block = { lang = { bg = { 35, 42, 32 } } }, }, } -- Compute a dark-tinted background from a status/accent color and a base dark color. -- Falls back to base for non-RGB values (ANSI color names in terminal theme). local function dark_tint(color, base) if type(color) ~= "table" or type(base) ~= "table" then return base end return { math.floor(base[1] * 0.6 + color[1] * 0.1), math.floor(base[2] * 0.6 + color[2] * 0.1), math.floor(base[3] * 0.6 + color[3] * 0.1), } end local build_errors = function(p) return { -- builtin_markdown is used for markdown-formatted error messages in shell builtin_markdown = { fg = p.bg_raised, code = { fg = p.special1, bg = p.bg_deep, s = "bold" }, strong = { s = "inverted,bold" }, }, } end local build_builtin = function(p) return { err = { fg = p.fg3 }, ls = { offset = { fg = p.fg1 }, user = { fg = p.fg4 }, group = { fg = p.fg5 }, atime = { fg = p.warm3 }, perms = { fg = p.fg1 }, size = { fg = p.fg2, s = "bold" }, dir = { fg = p.accent3, s = "bold", before = "ⓓ " }, file = { fg = p.fg8, before = "ⓕ " }, target = { fg = p.accent4 }, link = { fg = p.accent2, before = "ⓛ " }, socket = { fg = p.special1, before = "ⓢ " }, block = { fg = p.warning, before = "ⓑ " }, pipe = { fg = p.error, before = "ⓟ " }, char = { fg = p.warm1, before = "ⓒ " }, unknown = { fg = p.special1, before = "ⓤ " }, exec = { fg = p.success, s = "bold", before = " " }, }, alias = { name = { fg = p.fg5, s = "bold", align = "right" }, value = { fg = p.fg4, text_indent = 4 }, }, prompt = { enabled = { fg = p.success, s = "bold" }, disabled = { fg = p.fg1 }, }, pyvenv = { active = { fg = p.accent2, s = "bold" }, inactive = { fg = p.fg1 }, }, cat = { line_num = { fg = p.fg4 }, }, envlist = { var = { fg = p.fg6, s = "bold", align = "left" }, value = { fg = p.fg4, text_indent = 4 }, }, dig = { name = { fg = p.warm2, s = "bold" }, _in = { fg = p.fg3 }, _type = { fg = p.caution }, content = { fg = p.accent3 }, ttl = { fg = p.warm3 }, ns = { fg = p.fg1, s = "bold" }, ns_type = { fg = p.fg3 }, query = { fg = p.fg3, s = "bold,underlined" }, answer = { fg = p.fg3 }, domain = { fg = p.fg7, s = "bold" }, root_ns = { fg = p.fg3, s = "inverted" }, tld = { fg = p.fg4, s = "inverted" }, tld_ns = { fg = p.fg5, s = "bold" }, rtype = { s = "bold" }, domain_ns = { fg = p.fg6, s = "bold" }, dnssec_secure = { fg = p.success, s = "bold" }, dnssec_insecure = { fg = p.warning, s = "bold" }, dnssec_bogus = { fg = p.error, s = "bold" }, dnssec_indeterminate = { fg = p.fg4 }, }, netstat = { src = { fg = p.fg2 }, dst = { fg = p.fg4 }, state = { fg = p.fg7 }, user = { fg = p.fg1 }, }, history = { date = { fg = p.warm1, after = " " }, time = { fg = p.warm2, before = "[", after = "] " }, duration = {}, status = {}, cmd = { ok = { fg = p.fg7 }, fail = { fg = p.special1, s = "bold" } }, }, wg = { net = { name = { fg = p.success, s = "bold", after = " " }, pub_key = { fg = p.warm2, s = "italic" }, }, endpoint = { text_indent = 4, name = { fg = p.warm1, s = "bold", after = " " }, pub_key = { text_indent = 0, fg = p.warm2, s = "italic" }, bytes = { fg = p.fg4 }, seen = { fg = p.warm3 }, nets = { s = "dim,bold", fg = p.special2, }, }, }, pager = { line_num = { fg = p.bg_raised, selected = { s = "bold,inverted" } }, search_match = { fg = p.bg_deep, bg = p.success, s = "italic,bold" }, element = { focused = { fg = p.warning, s = "bold" }, }, status_line = { fg = p.accent2, filename = { after = " ※ " }, total_lines = { after = " ※ " }, size = { after = " ※ " }, position = { s = "bold,italic" }, search = { input = { bg = p.success, fg = p.bg_deep, s = "bold,inverted" }, pattern = { before = "/", s = "bold" }, }, render_mode = { after = " ※ ", before = " ※ " }, url = { fg = p.accent3 }, hint = { fg = p.accent4, s = "italic", before = " ※ " }, codeblock = { fg = p.bg_raised, lang = { before = " [", fg = p.bg_surface, after = "]" } }, notification = { fg = p.special1, s = "italic" }, }, }, } end local build_prompt = function(p) return { llm = { chat = { fg = p.accent3 }, backend = { fg = p.accent1, s = "bold" }, preset = { fg = p.accent1, s = "bold", before = "", after = "" }, model = { fg = p.success, before = "", after = "" }, embedding = { fg = p.accent2, before = "Ⓔ " }, endpoint = { fg = p.accent2, chat = { content = " " }, complete = { content = " " } }, prompt = { fg = p.accent3, before = "⁜", after = "⁜" }, temperature = { fg = p.special2, before = "" }, ctx = { fg = p.success, before = "" }, tokens = { fg = p.warning, before = "" }, rate = { fg = p.warm3, before = "" }, total_cost = { fg = p.warm2, before = " " }, }, lua = { logo = { fg = p.accent3, s = "bold", content = "" }, }, wiki = { sep = { fg = p.bg_raised }, logo = { fg = p.warm1, content = " " }, db_name = { fg = p.warm3, s = "bold" }, }, shell = { sep = { fg = p.warm2 }, git = { logo = { fg = p.accent1, s = "dim", content = " " }, branch = { w = 15, clean = { fg = p.success }, dirty = { fg = p.warning }, }, modified = { fg = p.caution, before = "←" }, staged = { fg = p.success, before = "←" }, untracked = { fg = p.special1, before = "…" }, ahead = { fg = p.success, before = "↑⬝" }, behind = { fg = p.warning, before = "↓⬝" }, remote = { fg = p.accent4 }, tag = { fg = p.success, w = 20, clip = 5 }, tag_sep = { fg = p.warm2, content = "@" }, }, user = { user = { fg = p.success }, root = { fg = p.error }, hostname = { fg = p.warning }, }, secrets = { unlocked = { fg = p.warning, s = "bold", content = " " }, error = { fg = p.caution, s = "bold", content = "✘" }, }, python = { logo = { fg = p.warning, content = " ", s = "bold" }, env = { fg = p.accent2, s = "bold,dim", bg = dark_tint(p.accent4, p.bg_deep) }, }, dir = { fg = p.fg8, w = 25, clip = 3 }, }, } end local build_repl = function(p) return { lua = { separator = { fg = p.accent3, s = "bold", content = "-", fill = true, w = 0.4 }, }, } end local build_wiki = function(p) return { separator = { fg = p.accent4, s = "bold", content = "-", fill = true, w = 0.4 }, item = { fg = p.fg4 }, key = { fg = p.accent4 }, title = { s = "bold", fg = p.fg7 }, score = { fg = p.bg_surface }, } end local build_completion = function(p) return { fg = p.fg5, builtin = { fg = p.accent4 }, fs_exe = { fg = p.special1 }, bin = { fg = p.fg5, s = "bold" }, env = { s = "bold" }, lua_keyword = { fg = p.accent3, s = "bold" }, lua_symbol = { fg = p.accent4 }, history = { text_indent = 4, fg = p.warm3, s = "bold,italic" }, dir_history = { text_indent = 4, fg = p.fg4, s = "bold,italic" }, snippet = { text_indent = 4, fg = p.accent1, s = "bold" }, } end local build_input = function(p) return { input = { fg = p.fg8, -- typed text color (matches hard-coded default in term.input) s = "normal", blank = { w = 1 }, }, completion = { fg = p.fg5, -- inline ghost-text color (matches hard-coded default in term.input) }, } end local build_markdown = function(p) local md_default_borders = { align = "none", indent = 0, fg = p.bg_surface, clip = -1, top_line = { before = "╭", content = "─", after = "╮", fill = true, clip = -1 }, bottom_line = { before = "╰", content = "─", after = "╯", fill = true, clip = -1 }, subtitle_line = { before = "├", content = "─", after = "┤", fill = true, clip = -1 }, v = { content = "│", w = 1, clip = -1 }, } local colored_borders = function(fg) local b = {} for k, v in pairs(md_default_borders) do b[k] = v end b.fg = fg return b end local md_table_borders = { top_left = "┌", top = "─", top_mid = "┬", top_right = "┐", left = "│", mid = "│", right = "│", mid_left = "├", mid_mid = "┼", mid_right = "┤", bottom_left = "└", bottom = "─", bottom_mid = "┴", bottom_right = "┘", fg = p.bg_surface, clip = -1, } return { wrap = 85, global_indent = 0, fg = p.text, heading = { s = "bold", fg = p.special2, h1 = { ts = "triple" }, h2 = { ts = "double" }, h3 = { ts = "big", before = "⁜⁜⁜ " }, h4 = { before = "⁜⁜⁜⁜ " }, h5 = { before = "⁜⁜⁜⁜⁜ " }, h6 = { before = "⁜⁜⁜⁜⁜⁜ " }, }, code_block = { pad = 1, border = md_default_borders, fg = p.warm3, text_fg = p.text, lang = { s = "italic,bold", before = "⧼ ", after = " ⧽", bg = dark_tint(p.warm3, p.bg_deep), fg = p.warm3, }, }, para = {}, strong = { s = "bold", fg = p.warm1 }, emph = { s = "italic", fg = p.warning }, code = { fg = p.fg5, s = "bold", tbl = { fg = p.special1, before = "{", after = "}" }, fn = { before = "ʄ(", after = ")", fg = p.special2 }, num = { fg = p.warm1 }, str = { fg = p.success }, bool = { fg = p.accent1 }, file = { before = "Ⓕ ", fg = p.accent2 }, dir = { before = "Ⓓ ", fg = p.accent4 }, opt = { s = "italic" }, req = { s = "bold" }, def = { s = "underlined" }, or_nil = { s = "dim" }, err = { fg = p.error, s = "dim" }, arg = { fg = p.warm3 }, flag = { fg = p.accent3, s = "bold" }, meta = { fg = p.fg4, s = "italic" }, neg = { fg = p.error }, multi = { fg = p.caution }, status = { fg = p.accent1, s = "italic" }, date = { ts = "compact", s = "dim" }, }, strikethrough = { s = "strikethrough", fg = p.fg3 }, link = { title = { s = "underlined", fg = p.accent3 }, url = { fg = p.accent3, s = "dim", before = " (", after = ")", clip = 0, w = 0.2 }, }, image = { alt = { s = "italic", before = "[img: ", after = "]" }, url = { s = "dim", before = " (", after = ")" }, }, thematic_break = { w = 0.5, align = "center", content = "⁓", fill = true, fg = p.bg_surface, }, list = { indent_per_level = 4, }, list_item = { ul = { content = "⦁ ", fg = p.accent4, }, ol = { fg = p.fg3, }, }, task_list = { checked = { content = "❴❵ ", fg = p.success }, unchecked = { content = "❴ ❵ ", fg = p.fg3 }, }, table = { overflow = "wrap", border = md_table_borders, header = { s = "bold", fg = p.caution, align = "center" }, cell = { fg = p.fg8 }, }, footnote_ref = { ts = "superscript", s = "bold", fg = p.accent3 }, footnotes = { separator = { fg = p.bg_surface }, }, footnote = { marker = { s = "bold", fg = p.accent3 }, content = { fg = p.fg3 }, }, div = { default = { align = "center", w = 0.8, pad = 1, border = md_default_borders, fg = p.fg8, label = { s = "italic,bold", before = "⧼ ", after = " ⧽", fg = p.accent4, w = 0 }, }, warning = { fg = p.caution, border = colored_borders(p.caution), label = { s = "italic,bold", before = "⧼ ", after = " ⧽", bg = dark_tint(p.caution, p.bg_mid), fg = p.caution, }, }, note = { fg = p.accent3, border = colored_borders(p.accent3), label = { s = "italic,bold", before = "⧼ ", after = " ⧽", bg = dark_tint(p.accent3, p.bg_mid), fg = p.accent3, }, }, tip = { fg = p.success, border = colored_borders(p.success), label = { s = "italic,bold", before = "⧼ ", after = " ⧽", bg = dark_tint(p.success, p.bg_mid), fg = p.success, }, }, caution = { fg = p.error, border = colored_borders(p.error), label = { s = "italic,bold", before = "⧼ ", after = " ⧽", bg = dark_tint(p.error, p.bg_mid), fg = p.error, }, }, }, blockquote = { fg = p.fg3, indent = 0, bar = { content = "┃ ", fg = p.accent3 }, }, } end local build_widget = function(p) return { fg = p.fg8, title = { s = "bold", align = "center" }, border = { fg = p.bg_surface, align = "none", clip = -1, top_line = { before = "┌", content = "─", after = "┐", fill = true, clip = -1 }, bottom_line = { before = "└", content = "─", after = "┘", fill = true, clip = -1 }, separator = { before = "├", content = "─", after = "┤", fill = true, clip = -1 }, v = { content = "│", w = 1, clip = -1 }, label = { w = 0.8, clip = 7 }, }, chooser = { item = { align = "center", bg = p.bg_mid }, selected = { s = "inverted", align = "center" }, marked = { content = "◉ ", s = "bold", w = 2 }, unmarked = { content = " ", w = 2 }, }, file = { item = { text_indent = 1, align = "left" }, selected = { text_indent = 1, align = "left", s = "inverted" }, directory = { text_indent = 1, align = "left", s = "bold" }, search_text = { text_indent = 1, align = "left" }, search_placeholder = { text_indent = 1, align = "left", s = "italic", fg = p.fg3 }, pinned = { text_indent = 1, align = "left", s = "bold", fg = p.accent1 }, rel_path = { fg = p.fg2, s = "italic" }, no_results = { align = "center", s = "italic", fg = p.fg3 }, }, confirm = { yes = { s = "bold" }, no = {}, selected = { s = "inverted" }, }, form = { label = { text_indent = 1, align = "left", after = ": ", s = "bold" }, focused = { text_indent = 1, align = "left", after = ": ", s = "bold,inverted" }, input = { s = "italic", line = { content = "_", fill = true }, blank = { w = 1 } }, }, scrollbar = { track = { fg = p.bg_surface, content = "│" }, thumb = { fg = p.fg3, content = "┃" }, }, } end local build_lem = function(p) return { gutter = { fg = p.fg3, line_number = { fg = p.bg_surface }, current_line = { fg = p.fg4, s = "bold" }, diagnostic_error = { fg = p.error, s = "bold" }, wrap_indicator = { fg = p.fg2 }, }, status = { bg = p.bg_deep, fg = p.fg5, filename = { fg = p.fg7 }, modified = { fg = p.warning, s = "bold" }, position = { fg = p.fg6 }, mode = { fg = p.accent1, s = "bold" }, mode_insert = { fg = p.success, s = "bold" }, mode_visual = { fg = p.special2, s = "bold" }, filetype = { fg = p.fg4 }, buffer_index = { fg = p.fg4 }, }, command = { input = { fg = p.fg7 }, prompt = { fg = p.accent1, s = "bold" }, }, message = { info = { fg = p.fg5 }, warning = { fg = p.warning }, error = { fg = p.error }, }, text = { fg = p.text, selection = { bg = p.bg_raised }, search_match = { bg = p.warning, fg = p.bg_deep }, bracket_match = { bg = p.fg4, fg = p.bg_deep, s = "bold" }, diagnostic = { fg = p.error, s = "underlined" }, current_line = { bg = p.bg_mid }, }, syntax = { literal = { fg = p.success }, definition = { fg = p.warning, s = "bold" }, comment = { fg = p.accent3, s = "italic" }, disabled = { fg = p.warm2 }, error = { fg = p.error }, punctuation = { fg = p.fg3 }, operator = { fg = p.fg4 }, directive = { fg = p.fg2, s = "italic" }, }, completion = { fg = p.fg3, s = "italic", }, } end -- Registered LILPACK section builders: name -> function(palette) -> rss_table local registered_sections = {} -- Registered LILPACK themes: name -> { palette = {...}, ... } local registered_themes = {} local build_defaults = function(p) local d = { shell = { errors = build_errors(p), builtin = build_builtin(p), prompt = build_prompt(p), completion = build_completion(p), input = build_input(p), repl = build_repl(p), wiki = build_wiki(p), widget = build_widget(p), lem = build_lem(p), }, markdown = build_markdown(p), } for name, builder_fn in pairs(registered_sections) do d[name] = builder_fn(p) end return d end local active_palette = std.tbl.copy(default_palette) local defaults = build_defaults(active_palette) local active_theme_name = "default" local active_theme_data = {} local set = function(name) if name == "default" then active_palette = std.tbl.copy(default_palette) defaults = build_defaults(active_palette) active_theme_name = "default" active_theme_data = {} for section, _ in pairs(defaults) do active_theme_data[section] = std.tbl.copy(default_overrides[section] or {}) end generation = generation + 1 rss_cache = {} return true end -- Check registered LILPACK themes first, then bundled catalog local data = registered_themes[name] if not data then local ok, catalog = pcall(require, "theme.catalog") if ok and catalog[name] then data = catalog[name] end end if not data then return nil, "theme not found: " .. name end -- Merge palette: default -> theme active_palette = std.tbl.copy(default_palette) if data.palette then active_palette = std.tbl.merge(active_palette, std.tbl.copy(data.palette)) end defaults = build_defaults(active_palette) -- Element-level overrides from theme active_theme_name = name active_theme_data = {} for section, _ in pairs(defaults) do active_theme_data[section] = data[section] or {} end generation = generation + 1 rss_cache = {} return true end -- Initialize with the built-in default (applies default_overrides), sharing the -- same code path as a runtime `set("default")`. set("default") local current = function() return active_theme_name end local list = function() local result = {} local seen = {} -- Bundled themes local ok, catalog = pcall(require, "theme.catalog") if ok then for name, _ in pairs(catalog) do if not seen[name] then table.insert(result, { name = name, source = "bundled" }) seen[name] = true end end end -- Registered LILPACK themes for name, _ in pairs(registered_themes) do if not seen[name] then table.insert(result, { name = name, source = "lilpack" }) seen[name] = true end end table.sort(result, function(a, b) return a.name < b.name end) return result end local get = function(section, extra_overrides) local key = section or "" if not rss_cache[key] then local merged = std.tbl.copy(defaults[key] or {}) merged = std.tbl.merge(merged, std.tbl.copy(active_theme_data[key] or {})) rss_cache[key] = merged end local result = std.tbl.copy(rss_cache[key]) if type(extra_overrides) == "table" then result = std.tbl.merge(result, extra_overrides) end return result end local subscribe = function(section, sub_key) local gen local cached_tss return function() if generation ~= gen then local rss = get(section) if sub_key then rss = rss[sub_key] or {} end cached_tss = style.new(rss) gen = generation end return cached_tss end end local get_palette = function() return std.tbl.copy(active_palette) end local register_section = function(name, builder_fn) registered_sections[name] = builder_fn defaults[name] = builder_fn(active_palette) generation = generation + 1 rss_cache = {} end local register_theme = function(name, data) registered_themes[name] = data end return { get = get, set = set, current = current, list = list, subscribe = subscribe, palette = get_palette, register_section = register_section, register_theme = register_theme, }