commit 60c2fd873974657d05fa487438097b81734fc24f Author: Tai Groot Date: Wed Feb 18 19:31:33 2026 -0500 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..86a62eb --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# glaze.nvim 󱓞 + +> A **Mason/Lazy-style** interface for managing Go binaries across Neovim plugins. +> Charmbracelet-inspired aesthetic. Zero duplication. One source of truth. + +![Lua](https://img.shields.io/badge/Lua-2C2D72?style=flat&logo=lua&logoColor=white) +![Neovim](https://img.shields.io/badge/Neovim%200.9+-57A143?style=flat&logo=neovim&logoColor=white) + +## ✨ Features + +- **Centralized binary management** — Register binaries from any plugin, update them all at once +- **Lazy.nvim-style UI** — Floating window with progress bars, spinners, and status indicators +- **Parallel installations** — Configurable concurrency for fast updates +- **Charmbracelet aesthetic** — Pink/magenta color scheme that matches the Charm toolchain +- **Zero config for dependents** — Just register and go +- **Callback support** — Get notified when your binary is updated + +## 📦 Installation + +Using [lazy.nvim](https://github.com/folke/lazy.nvim): + +```lua +{ + "taigrr/glaze.nvim", + config = function() + require("glaze").setup({}) + end, +} +``` + +## 🚀 Quick Start + +```lua +local glaze = require("glaze") + +-- Setup (usually in your plugin config) +glaze.setup({}) + +-- Register binaries +glaze.register("freeze", "github.com/charmbracelet/freeze") +glaze.register("glow", "github.com/charmbracelet/glow") +glaze.register("mods", "github.com/charmbracelet/mods") +``` + +## 📖 Usage + +### Commands + +| Command | Description | +|---------|-------------| +| `:Glaze` | Open the Glaze UI | +| `:GlazeUpdate [name]` | Update all or specific binary | +| `:GlazeInstall [name]` | Install missing or specific binary | + +### Keybinds (in Glaze UI) + +| Key | Action | +|-----|--------| +| `u` | Update all binaries | +| `i` | Install missing binaries | +| `x` | Abort running tasks | +| `q` / `` | Close window | + +## 🔌 For Plugin Authors + +Register your plugin's binaries as a dependency: + +```lua +-- In your plugin's setup or init: +local ok, glaze = pcall(require, "glaze") +if ok then + glaze.register("mytool", "github.com/me/mytool", { + plugin = "myplugin.nvim", -- Shows in UI + callback = function(success) + if success then + vim.notify("mytool updated!") + end + end, + }) +end +``` + +### Providing Update Commands + +You can still expose plugin-specific commands that delegate to Glaze: + +```lua +vim.api.nvim_create_user_command("MyPluginUpdate", function() + local glaze = require("glaze") + require("glaze.runner").update({ "mytool" }) +end, {}) +``` + +## ⚙️ Configuration + +```lua +require("glaze").setup({ + ui = { + border = "rounded", -- "none", "single", "double", "rounded", "solid", "shadow" + size = { width = 0.7, height = 0.8 }, -- Percentage of screen + icons = { + pending = "○", + running = "◐", + done = "●", + error = "✗", + binary = "󰆍", + }, + }, + concurrency = 4, -- Max parallel installations + go_cmd = { "go" }, -- Auto-detects goenv if available +}) +``` + +## 🎨 Highlight Groups + +Glaze defines these highlight groups (all prefixed with `Glaze`): + +| Group | Description | +|-------|-------------| +| `GlazeH1` | Main title (pink) | +| `GlazeH2` | Section headers | +| `GlazeBinary` | Binary names | +| `GlazeUrl` | Module URLs | +| `GlazePlugin` | Plugin names | +| `GlazeDone` | Success status | +| `GlazeError` | Error status | +| `GlazeRunning` | In-progress status | +| `GlazeProgressDone` | Progress bar (filled) | +| `GlazeProgressTodo` | Progress bar (empty) | + +## 📋 API + +```lua +local glaze = require("glaze") + +-- Registration +glaze.register(name, url, opts?) -- Register a binary +glaze.unregister(name) -- Remove a binary +glaze.binaries() -- Get all registered binaries + +-- Status +glaze.is_installed(name) -- Check if binary exists +glaze.status(name) -- "installed", "missing", or "unknown" + +-- Runner (for programmatic control) +local runner = require("glaze.runner") +runner.update({ "freeze", "glow" }) -- Update specific binaries +runner.update_all() -- Update all +runner.install({ "freeze" }) -- Install specific +runner.install_missing() -- Install all missing +runner.abort() -- Stop all tasks +runner.is_running() -- Check if tasks are running +runner.tasks() -- Get current task list +runner.stats() -- Get { total, done, error, running, pending } +``` + +## 🤝 Related Projects + +- [freeze.nvim](https://github.com/taigrr/freeze.nvim) — Screenshot code with freeze +- [neocrush.nvim](https://github.com/taigrr/neocrush.nvim) — AI-powered coding assistant +- [lazy.nvim](https://github.com/folke/lazy.nvim) — UI inspiration +- [mason.nvim](https://github.com/williamboman/mason.nvim) — Concept inspiration + +## 📄 License + +MIT diff --git a/doc/glaze.txt b/doc/glaze.txt new file mode 100644 index 0000000..e18b39d --- /dev/null +++ b/doc/glaze.txt @@ -0,0 +1,151 @@ +*glaze.txt* Centralized Go binary management for Neovim plugins + +Author: Tai Groot +License: MIT +Homepage: https://github.com/taigrr/glaze.nvim + +============================================================================== +CONTENTS *glaze-contents* + + 1. Introduction ........................... |glaze-introduction| + 2. Setup .................................. |glaze-setup| + 3. Commands ............................... |glaze-commands| + 4. API .................................... |glaze-api| + 5. Configuration .......................... |glaze-config| + 6. Highlights ............................. |glaze-highlights| + 7. For Plugin Authors ..................... |glaze-plugin-authors| + +============================================================================== +1. INTRODUCTION *glaze-introduction* + +Glaze is a centralized manager for Go binaries used by Neovim plugins. Instead +of each plugin implementing its own binary installer, Glaze provides: + +- A unified UI for managing all Go binaries +- Parallel installation with progress tracking +- A simple registration API for plugin authors + +============================================================================== +2. SETUP *glaze-setup* + +Using lazy.nvim: +>lua + { + "taigrr/glaze.nvim", + config = function() + require("glaze").setup({}) + end, + } +< + +============================================================================== +3. COMMANDS *glaze-commands* + + *:Glaze* +:Glaze Open the Glaze UI window. + + *:GlazeUpdate* +:GlazeUpdate [name] Update all registered binaries, or a specific one. + + *:GlazeInstall* +:GlazeInstall [name] Install missing binaries, or a specific one. + +============================================================================== +4. API *glaze-api* + + *glaze.setup()* +glaze.setup({opts}) + Initialize Glaze with optional configuration. + + *glaze.register()* +glaze.register({name}, {url}, {opts?}) + Register a binary for management. + + Parameters: ~ + {name} Binary/executable name (string) + {url} Go module URL without version (string) + {opts} Optional table with: + - plugin: Name of the registering plugin (string) + - callback: Function called after install/update (function) + + *glaze.unregister()* +glaze.unregister({name}) + Remove a binary from management. + + *glaze.binaries()* +glaze.binaries() + Returns all registered binaries as a table. + + *glaze.is_installed()* +glaze.is_installed({name}) + Returns true if the binary is in PATH. + + *glaze.status()* +glaze.status({name}) + Returns "installed", "missing", or "unknown". + +============================================================================== +5. CONFIGURATION *glaze-config* + +Default configuration: +>lua + require("glaze").setup({ + ui = { + border = "rounded", + size = { width = 0.7, height = 0.8 }, + icons = { + pending = "○", + running = "◐", + done = "●", + error = "✗", + binary = "󰆍", + }, + }, + concurrency = 4, + go_cmd = { "go" }, -- Auto-detects goenv + }) +< + +============================================================================== +6. HIGHLIGHTS *glaze-highlights* + +All highlight groups are prefixed with "Glaze": + + GlazeH1 Main title + GlazeH2 Section headers + GlazeBinary Binary names + GlazeUrl Module URLs + GlazePlugin Plugin names + GlazeDone Success status + GlazeError Error status + GlazeRunning In-progress status + GlazeProgressDone Progress bar (filled) + GlazeProgressTodo Progress bar (empty) + +============================================================================== +7. FOR PLUGIN AUTHORS *glaze-plugin-authors* + +Register your binaries in your plugin's setup: +>lua + local ok, glaze = pcall(require, "glaze") + if ok then + glaze.register("mytool", "github.com/me/mytool", { + plugin = "myplugin.nvim", + callback = function(success) + if success then + vim.notify("mytool updated!") + end + end, + }) + end +< + +You can still provide plugin-specific update commands: +>lua + vim.api.nvim_create_user_command("MyPluginUpdate", function() + require("glaze.runner").update({ "mytool" }) + end, {}) +< + +============================================================================== +vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/glaze/colors.lua b/lua/glaze/colors.lua new file mode 100644 index 0000000..c2aa979 --- /dev/null +++ b/lua/glaze/colors.lua @@ -0,0 +1,76 @@ +---@brief [[ +--- glaze.nvim color definitions +--- Charmbracelet-inspired palette with Lazy.nvim structure +---@brief ]] + +local M = {} + +-- Charmbracelet-inspired colors (pink/magenta theme) +M.colors = { + -- Headers and accents + H1 = { fg = "#FF6AD5", bold = true }, -- Charm pink + H2 = { fg = "#C4A7E7", bold = true }, -- Soft purple + Title = { fg = "#FF6AD5", bold = true }, + + -- Status indicators + Done = { fg = "#9FFFCB" }, -- Mint green (success) + Running = { fg = "#FFD866" }, -- Warm yellow (in progress) + Pending = { fg = "#6E6A86" }, -- Muted gray + Error = { fg = "#FF6B6B", bold = true }, -- Soft red + + -- Content + Normal = "NormalFloat", + Binary = { fg = "#FF6AD5" }, -- Charm pink for binary names + Url = { fg = "#7DCFFF", italic = true }, -- Bright blue for URLs + Plugin = { fg = "#BB9AF7" }, -- Purple for plugin names + Comment = "Comment", + Dimmed = "Conceal", + + -- Progress bar + ProgressDone = { fg = "#FF6AD5" }, -- Charm pink + ProgressTodo = { fg = "#3B3A52" }, -- Dark purple-gray + + -- UI elements + Border = { fg = "#FF6AD5" }, + Button = "CursorLine", + ButtonActive = { bg = "#FF6AD5", fg = "#1A1B26", bold = true }, + Key = { fg = "#FFD866", bold = true }, -- Keybind highlights + + -- Version info + Version = { fg = "#9FFFCB" }, + Time = { fg = "#6E6A86", italic = true }, + + -- Icons + Icon = { fg = "#FF6AD5" }, + IconDone = { fg = "#9FFFCB" }, + IconError = { fg = "#FF6B6B" }, + IconRunning = { fg = "#FFD866" }, + + Bold = { bold = true }, + Italic = { italic = true }, +} + +M.did_setup = false + +function M.set_hl() + for name, def in pairs(M.colors) do + local hl = type(def) == "table" and def or { link = def } + hl.default = true + vim.api.nvim_set_hl(0, "Glaze" .. name, hl) + end +end + +function M.setup() + if M.did_setup then + return + end + M.did_setup = true + + M.set_hl() + + vim.api.nvim_create_autocmd("ColorScheme", { + callback = M.set_hl, + }) +end + +return M diff --git a/lua/glaze/float.lua b/lua/glaze/float.lua new file mode 100644 index 0000000..dbfd709 --- /dev/null +++ b/lua/glaze/float.lua @@ -0,0 +1,190 @@ +---@brief [[ +--- glaze.nvim floating window (based on lazy.nvim) +--- Creates centered floating window with backdrop +---@brief ]] + +local M = {} + +---@class GlazeFloatOptions +---@field size? { width: number, height: number } +---@field border? string +---@field title? string +---@field zindex? number + +---@class GlazeFloat +---@field buf number +---@field win number +---@field opts GlazeFloatOptions +---@field win_opts table +---@field backdrop_buf? number +---@field backdrop_win? number +local Float = {} + +local _id = 0 + +---@param opts? GlazeFloatOptions +---@return GlazeFloat +function Float.new(opts) + local self = setmetatable({}, { __index = Float }) + return self:init(opts) +end + +---@param opts? GlazeFloatOptions +function Float:init(opts) + require("glaze.colors").setup() + + _id = _id + 1 + self.id = _id + + local config = require("glaze").config + self.opts = vim.tbl_deep_extend("force", { + size = config.ui.size, + border = config.ui.border, + zindex = 50, + }, opts or {}) + + self.win_opts = { + relative = "editor", + style = "minimal", + border = self.opts.border, + zindex = self.opts.zindex, + title = self.opts.title, + title_pos = self.opts.title and "center" or nil, + } + + self:mount() + return self +end + +function Float:layout() + local function size(max, value) + return value > 1 and math.min(value, max) or math.floor(max * value) + end + + self.win_opts.width = size(vim.o.columns, self.opts.size.width) + self.win_opts.height = size(vim.o.lines - 4, self.opts.size.height) + self.win_opts.row = math.floor((vim.o.lines - self.win_opts.height) / 2) + self.win_opts.col = math.floor((vim.o.columns - self.win_opts.width) / 2) + + if self.opts.border ~= "none" then + self.win_opts.row = self.win_opts.row - 1 + self.win_opts.col = self.win_opts.col - 1 + end +end + +function Float:mount() + self.buf = vim.api.nvim_create_buf(false, true) + + -- Create backdrop + local normal = vim.api.nvim_get_hl(0, { name = "Normal" }) + if normal.bg and vim.o.termguicolors then + self.backdrop_buf = vim.api.nvim_create_buf(false, true) + self.backdrop_win = vim.api.nvim_open_win(self.backdrop_buf, false, { + relative = "editor", + width = vim.o.columns, + height = vim.o.lines, + row = 0, + col = 0, + style = "minimal", + focusable = false, + zindex = self.opts.zindex - 1, + }) + vim.api.nvim_set_hl(0, "GlazeBackdrop", { bg = "#000000", default = true }) + vim.wo[self.backdrop_win].winhighlight = "Normal:GlazeBackdrop" + vim.wo[self.backdrop_win].winblend = 60 + vim.bo[self.backdrop_buf].buftype = "nofile" + end + + self:layout() + self.win = vim.api.nvim_open_win(self.buf, true, self.win_opts) + + -- Buffer settings + vim.bo[self.buf].buftype = "nofile" + vim.bo[self.buf].bufhidden = "wipe" + vim.bo[self.buf].filetype = "glaze" + + -- Window settings + vim.wo[self.win].conceallevel = 3 + vim.wo[self.win].foldenable = false + vim.wo[self.win].spell = false + vim.wo[self.win].wrap = true + vim.wo[self.win].winhighlight = "Normal:GlazeNormal,FloatBorder:GlazeBorder" + vim.wo[self.win].cursorline = true + + -- Keymaps + self:map("q", function() + self:close() + end, "Close") + self:map("", function() + self:close() + end, "Close") + + -- Auto-close on WinClosed + vim.api.nvim_create_autocmd("WinClosed", { + pattern = tostring(self.win), + once = true, + callback = function() + self:close() + end, + }) + + -- Handle resize + vim.api.nvim_create_autocmd("VimResized", { + callback = function() + if not self:valid() then + return true + end + self:layout() + vim.api.nvim_win_set_config(self.win, { + relative = "editor", + width = self.win_opts.width, + height = self.win_opts.height, + row = self.win_opts.row, + col = self.win_opts.col, + }) + if self.backdrop_win and vim.api.nvim_win_is_valid(self.backdrop_win) then + vim.api.nvim_win_set_config(self.backdrop_win, { + width = vim.o.columns, + height = vim.o.lines, + }) + end + end, + }) +end + +---@param key string +---@param fn function +---@param desc string +function Float:map(key, fn, desc) + vim.keymap.set("n", key, fn, { buffer = self.buf, nowait = true, desc = desc }) +end + +function Float:valid() + return self.win and vim.api.nvim_win_is_valid(self.win) +end + +function Float:close() + vim.schedule(function() + if self.backdrop_win and vim.api.nvim_win_is_valid(self.backdrop_win) then + vim.api.nvim_win_close(self.backdrop_win, true) + end + if self.backdrop_buf and vim.api.nvim_buf_is_valid(self.backdrop_buf) then + vim.api.nvim_buf_delete(self.backdrop_buf, { force = true }) + end + if self.win and vim.api.nvim_win_is_valid(self.win) then + vim.api.nvim_win_close(self.win, true) + end + if self.buf and vim.api.nvim_buf_is_valid(self.buf) then + vim.api.nvim_buf_delete(self.buf, { force = true }) + end + end) +end + +---@return number +function Float:width() + return self.win_opts.width +end + +M.Float = Float + +return M diff --git a/lua/glaze/init.lua b/lua/glaze/init.lua new file mode 100644 index 0000000..ae6738d --- /dev/null +++ b/lua/glaze/init.lua @@ -0,0 +1,148 @@ +---@brief [[ +--- glaze.nvim - Centralized Go binary management for Neovim plugins +--- +--- A Mason/Lazy-style interface for managing Go binaries across multiple plugins. +--- Register your plugin's binaries once, update them all with a single command. +--- +--- Usage: +--- require("glaze").setup({}) +--- require("glaze").register("freeze", "github.com/charmbracelet/freeze") +--- require("glaze").register("glow", "github.com/charmbracelet/glow") +---@brief ]] + +local M = {} + +---@class GlazeBinary +---@field name string Binary name (executable name) +---@field url string Go module URL (without @version) +---@field plugin? string Plugin that registered this binary +---@field callback? fun(success: boolean) Optional callback after install/update + +---@class GlazeConfig +---@field ui GlazeUIConfig +---@field concurrency number Max parallel installations +---@field go_cmd string[] Go command (supports goenv) + +---@class GlazeUIConfig +---@field border string Border style +---@field size { width: number, height: number } +---@field icons GlazeIcons + +---@class GlazeIcons +---@field pending string +---@field running string +---@field done string +---@field error string +---@field binary string + +---@type GlazeConfig +M.config = { + ui = { + border = "rounded", + size = { width = 0.7, height = 0.8 }, + icons = { + pending = "○", + running = "◐", + done = "●", + error = "✗", + binary = "󰆍", + }, + }, + concurrency = 4, + go_cmd = { "go" }, +} + +---@type table +M._binaries = {} + +---@type number? +M._ns = nil + +---@param opts? GlazeConfig +function M.setup(opts) + M.config = vim.tbl_deep_extend("force", M.config, opts or {}) + M._ns = vim.api.nvim_create_namespace("glaze") + + -- Auto-detect goenv + if vim.fn.executable("goenv") == 1 then + M.config.go_cmd = { "goenv", "exec", "go" } + end + + -- Create commands + vim.api.nvim_create_user_command("Glaze", function() + require("glaze.view").open() + end, { desc = "Open Glaze UI" }) + + vim.api.nvim_create_user_command("GlazeUpdate", function(cmd) + if cmd.args and cmd.args ~= "" then + require("glaze.runner").update({ cmd.args }) + else + require("glaze.runner").update_all() + end + end, { + desc = "Update Go binaries", + nargs = "?", + complete = function() + return vim.tbl_keys(M._binaries) + end, + }) + + vim.api.nvim_create_user_command("GlazeInstall", function(cmd) + if cmd.args and cmd.args ~= "" then + require("glaze.runner").install({ cmd.args }) + else + require("glaze.runner").install_missing() + end + end, { + desc = "Install Go binaries", + nargs = "?", + complete = function() + return vim.tbl_keys(M._binaries) + end, + }) +end + +---Register a binary for management. +---@param name string Binary/executable name +---@param url string Go module URL (e.g., "github.com/charmbracelet/freeze") +---@param opts? { plugin?: string, callback?: fun(success: boolean) } +function M.register(name, url, opts) + opts = opts or {} + M._binaries[name] = { + name = name, + url = url, + plugin = opts.plugin, + callback = opts.callback, + } +end + +---Unregister a binary. +---@param name string Binary name to unregister +function M.unregister(name) + M._binaries[name] = nil +end + +---Get all registered binaries. +---@return table +function M.binaries() + return M._binaries +end + +---Check if a binary is installed. +---@param name string Binary name +---@return boolean +function M.is_installed(name) + return vim.fn.executable(name) == 1 +end + +---Get binary installation status. +---@param name string Binary name +---@return "installed"|"missing"|"unknown" +function M.status(name) + if not M._binaries[name] then + return "unknown" + end + return M.is_installed(name) and "installed" or "missing" +end + +return M diff --git a/lua/glaze/runner.lua b/lua/glaze/runner.lua new file mode 100644 index 0000000..b029125 --- /dev/null +++ b/lua/glaze/runner.lua @@ -0,0 +1,242 @@ +---@brief [[ +--- glaze.nvim task runner +--- Handles parallel Go binary installation/updates +---@brief ]] + +local M = {} + +---@class GlazeTask +---@field binary GlazeBinary +---@field status "pending"|"running"|"done"|"error" +---@field output string[] +---@field start_time? number +---@field end_time? number +---@field job_id? number + +---@type GlazeTask[] +M._tasks = {} + +---@type function? +M._on_update = nil + +---@type boolean +M._running = false + +---Get all current tasks. +---@return GlazeTask[] +function M.tasks() + return M._tasks +end + +---Check if runner is active. +---@return boolean +function M.is_running() + return M._running +end + +---Set update callback (called when task status changes). +---@param fn function +function M.on_update(fn) + M._on_update = fn +end + +---@private +function M._notify() + if M._on_update then + vim.schedule(M._on_update) + end +end + +---@param binary GlazeBinary +---@return GlazeTask +local function create_task(binary) + return { + binary = binary, + status = "pending", + output = {}, + } +end + +---@param task GlazeTask +local function run_task(task) + local glaze = require("glaze") + local cmd = vim.list_extend({}, glaze.config.go_cmd) + table.insert(cmd, "install") + table.insert(cmd, task.binary.url .. "@latest") + + task.status = "running" + task.start_time = vim.uv.hrtime() + task.output = {} + M._notify() + + task.job_id = vim.fn.jobstart(cmd, { + on_stdout = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(task.output, line) + end + end + end + end, + on_stderr = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(task.output, line) + end + end + end + end, + on_exit = function(_, code) + task.end_time = vim.uv.hrtime() + task.status = code == 0 and "done" or "error" + task.job_id = nil + + -- Call binary callback if set + if task.binary.callback then + vim.schedule(function() + task.binary.callback(code == 0) + end) + end + + M._notify() + M._process_queue() + end, + }) +end + +---@private +function M._process_queue() + local glaze = require("glaze") + local running = 0 + local pending = {} + + for _, task in ipairs(M._tasks) do + if task.status == "running" then + running = running + 1 + elseif task.status == "pending" then + table.insert(pending, task) + end + end + + -- Start pending tasks up to concurrency limit + local to_start = math.min(#pending, glaze.config.concurrency - running) + for i = 1, to_start do + run_task(pending[i]) + end + + -- Check if all done + if running == 0 and #pending == 0 then + M._running = false + M._notify() + end +end + +---Run tasks for specified binaries. +---@param names string[] +---@param mode "install"|"update" +local function run(names, mode) + local glaze = require("glaze") + + -- Check for Go + local go_check = glaze.config.go_cmd[1] + if vim.fn.executable(go_check) ~= 1 then + vim.notify("Go is not installed. Please install Go first: https://go.dev/dl/", vim.log.levels.ERROR) + return + end + + -- Filter binaries + local binaries = {} + for _, name in ipairs(names) do + local binary = glaze._binaries[name] + if binary then + if mode == "install" and glaze.is_installed(name) then + -- Skip already installed + else + table.insert(binaries, binary) + end + else + vim.notify("Unknown binary: " .. name, vim.log.levels.WARN) + end + end + + if #binaries == 0 then + if mode == "install" then + vim.notify("All binaries already installed", vim.log.levels.INFO) + end + return + end + + -- Create tasks + M._tasks = {} + for _, binary in ipairs(binaries) do + table.insert(M._tasks, create_task(binary)) + end + + M._running = true + M._notify() + M._process_queue() + + -- Open UI + require("glaze.view").open() +end + +---Update specific binaries. +---@param names string[] +function M.update(names) + run(names, "update") +end + +---Update all registered binaries. +function M.update_all() + local glaze = require("glaze") + run(vim.tbl_keys(glaze._binaries), "update") +end + +---Install specific binaries. +---@param names string[] +function M.install(names) + run(names, "install") +end + +---Install all missing binaries. +function M.install_missing() + local glaze = require("glaze") + local missing = {} + for name, _ in pairs(glaze._binaries) do + if not glaze.is_installed(name) then + table.insert(missing, name) + end + end + if #missing > 0 then + run(missing, "install") + else + vim.notify("All binaries already installed", vim.log.levels.INFO) + end +end + +---Abort all running tasks. +function M.abort() + for _, task in ipairs(M._tasks) do + if task.job_id then + vim.fn.jobstop(task.job_id) + task.status = "error" + table.insert(task.output, "Aborted by user") + end + end + M._running = false + M._notify() +end + +---Get task statistics. +---@return { total: number, done: number, error: number, running: number, pending: number } +function M.stats() + local stats = { total = #M._tasks, done = 0, error = 0, running = 0, pending = 0 } + for _, task in ipairs(M._tasks) do + stats[task.status] = (stats[task.status] or 0) + 1 + end + return stats +end + +return M diff --git a/lua/glaze/text.lua b/lua/glaze/text.lua new file mode 100644 index 0000000..589bbc9 --- /dev/null +++ b/lua/glaze/text.lua @@ -0,0 +1,132 @@ +---@brief [[ +--- glaze.nvim text rendering (based on lazy.nvim) +--- Handles buffered text with highlight segments +---@brief ]] + +local M = {} + +---@class GlazeTextSegment +---@field str string +---@field hl? string|GlazeExtmark + +---@class GlazeExtmark +---@field hl_group? string +---@field col? number +---@field end_col? number +---@field virt_text? table +---@field virt_text_win_col? number + +---@class GlazeText +---@field _lines GlazeTextSegment[][] +---@field padding number +---@field wrap number +local Text = {} + +function Text.new() + local self = setmetatable({}, { __index = Text }) + self._lines = {} + self.padding = 2 + self.wrap = 80 + return self +end + +---@param str string +---@param hl? string|GlazeExtmark +---@param opts? { indent?: number, wrap?: boolean } +---@return GlazeText +function Text:append(str, hl, opts) + opts = opts or {} + if #self._lines == 0 then + self:nl() + end + + local lines = vim.split(str, "\n") + for i, line in ipairs(lines) do + if opts.indent then + line = string.rep(" ", opts.indent) .. line + end + if i > 1 then + self:nl() + end + -- Handle wrap + if opts.wrap and str ~= "" and self:col() > 0 and self:col() + vim.fn.strwidth(line) + self.padding > self.wrap then + self:nl() + end + table.insert(self._lines[#self._lines], { str = line, hl = hl }) + end + return self +end + +---@return GlazeText +function Text:nl() + table.insert(self._lines, {}) + return self +end + +---@return number +function Text:row() + return #self._lines == 0 and 1 or #self._lines +end + +---@return number +function Text:col() + if #self._lines == 0 then + return 0 + end + local width = 0 + for _, segment in ipairs(self._lines[#self._lines]) do + width = width + vim.fn.strwidth(segment.str) + end + return width +end + +function Text:trim() + while #self._lines > 0 and #self._lines[#self._lines] == 0 do + table.remove(self._lines) + end +end + +---@param buf number +---@param ns number +function Text:render(buf, ns) + local lines = {} + + for _, line in ipairs(self._lines) do + local str = string.rep(" ", self.padding) + for _, segment in ipairs(line) do + str = str .. segment.str + end + if str:match("^%s*$") then + str = "" + end + table.insert(lines, str) + end + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + + for l, line in ipairs(self._lines) do + if lines[l] ~= "" then + local col = self.padding + for _, segment in ipairs(line) do + local width = vim.fn.strwidth(segment.str) + local extmark = segment.hl + + if extmark then + if type(extmark) == "string" then + extmark = { hl_group = extmark, end_col = col + width } + end + ---@cast extmark GlazeExtmark + local extmark_col = extmark.col or col + extmark.col = nil + pcall(vim.api.nvim_buf_set_extmark, buf, ns, l - 1, extmark_col, extmark) + end + col = col + width + end + end + end +end + +M.Text = Text + +return M diff --git a/lua/glaze/view.lua b/lua/glaze/view.lua new file mode 100644 index 0000000..5519961 --- /dev/null +++ b/lua/glaze/view.lua @@ -0,0 +1,264 @@ +---@brief [[ +--- glaze.nvim view/UI +--- Lazy.nvim-style floating window with Charmbracelet aesthetic +---@brief ]] + +local M = {} + +---@type GlazeFloat? +M._float = nil + +---@type number? +M._timer = nil + +local SPINNERS = { "◐", "◓", "◑", "◒" } +local SPINNER_IDX = 1 + +---Open the Glaze UI. +function M.open() + local Float = require("glaze.float").Float + + if M._float and M._float:valid() then + M._float:close() + end + + M._float = Float.new({ + title = " 󱓞 Glaze ", + }) + + -- Set up keymaps + M._float:map("u", function() + require("glaze.runner").update_all() + end, "Update all") + + M._float:map("i", function() + require("glaze.runner").install_missing() + end, "Install missing") + + M._float:map("x", function() + require("glaze.runner").abort() + end, "Abort") + + M._float:map("", function() + M._toggle_details() + end, "Toggle details") + + -- Subscribe to runner updates + require("glaze.runner").on_update(function() + M.render() + end) + + -- Start update timer for spinner + M._start_timer() + + M.render() +end + +---@private +function M._start_timer() + if M._timer then + vim.fn.timer_stop(M._timer) + end + + M._timer = vim.fn.timer_start(100, function() + if not M._float or not M._float:valid() then + if M._timer then + vim.fn.timer_stop(M._timer) + M._timer = nil + end + return + end + + local runner = require("glaze.runner") + if runner.is_running() then + SPINNER_IDX = (SPINNER_IDX % #SPINNERS) + 1 + vim.schedule(function() + M.render() + end) + end + end, { ["repeat"] = -1 }) +end + +---@private +function M._toggle_details() + -- TODO: Implement detail expansion +end + +---Render the UI. +function M.render() + if not M._float or not M._float:valid() then + return + end + + local glaze = require("glaze") + local runner = require("glaze.runner") + local Text = require("glaze.text").Text + + local text = Text.new() + text.wrap = M._float:width() - 4 + + local icons = glaze.config.ui.icons + + -- Header + text:nl() + text:append(" ", "GlazeIcon"):append("Glaze", "GlazeH1"):append(" Go Binary Manager", "GlazeComment"):nl() + text:nl() + + -- Stats / Progress + local stats = runner.stats() + if stats.total > 0 then + M._render_progress(text, stats) + text:nl() + end + + -- Keybinds + text:append(" ", nil, { indent = 2 }) + text:append(" u ", "GlazeButtonActive"):append(" Update ", "GlazeButton") + text:append(" ") + text:append(" i ", "GlazeButtonActive"):append(" Install ", "GlazeButton") + text:append(" ") + text:append(" x ", "GlazeButtonActive"):append(" Abort ", "GlazeButton") + text:append(" ") + text:append(" q ", "GlazeButtonActive"):append(" Close ", "GlazeButton") + text:nl():nl() + + -- Tasks section (if running) + if stats.total > 0 then + text:append("Tasks", "GlazeH2"):append(" (" .. stats.done .. "/" .. stats.total .. ")", "GlazeComment"):nl() + text:nl() + + for _, task in ipairs(runner.tasks()) do + M._render_task(text, task, icons) + end + text:nl() + end + + -- Registered binaries + local binaries = glaze.binaries() + local binary_count = vim.tbl_count(binaries) + + text:append("Binaries", "GlazeH2"):append(" (" .. binary_count .. ")", "GlazeComment"):nl() + text:nl() + + if binary_count == 0 then + text:append("No binaries registered yet.", "GlazeComment", { indent = 4 }):nl() + text:append("Use ", "GlazeComment", { indent = 4 }) + text:append('require("glaze").register(name, url)', "GlazeUrl") + text:append(" to add binaries.", "GlazeComment"):nl() + else + -- Sort by name + local sorted = {} + for name, binary in pairs(binaries) do + table.insert(sorted, { name = name, binary = binary }) + end + table.sort(sorted, function(a, b) + return a.name < b.name + end) + + for _, item in ipairs(sorted) do + M._render_binary(text, item.binary, icons) + end + end + + text:trim() + + -- Render to buffer + vim.bo[M._float.buf].modifiable = true + text:render(M._float.buf, glaze._ns) + vim.bo[M._float.buf].modifiable = false +end + +---@param text GlazeText +---@param stats table +---@private +function M._render_progress(text, stats) + if not M._float then + return + end + + local width = M._float:width() - 6 + local done_ratio = stats.total > 0 and (stats.done / stats.total) or 0 + local done_width = math.floor(done_ratio * width + 0.5) + + if stats.done < stats.total then + text:append("", { + virt_text_win_col = 2, + virt_text = { { string.rep("━", done_width), "GlazeProgressDone" } }, + }) + text:append("", { + virt_text_win_col = 2 + done_width, + virt_text = { { string.rep("━", width - done_width), "GlazeProgressTodo" } }, + }) + end +end + +---@param text GlazeText +---@param task GlazeTask +---@param icons GlazeIcons +---@private +function M._render_task(text, task, icons) + local icon, icon_hl + if task.status == "done" then + icon, icon_hl = icons.done, "GlazeIconDone" + elseif task.status == "error" then + icon, icon_hl = icons.error, "GlazeIconError" + elseif task.status == "running" then + icon, icon_hl = SPINNERS[SPINNER_IDX], "GlazeIconRunning" + else + icon, icon_hl = icons.pending, "GlazePending" + end + + text:append(" " .. icon .. " ", icon_hl) + text:append(task.binary.name, "GlazeBinary") + + -- Time taken + if task.end_time and task.start_time then + local ms = (task.end_time - task.start_time) / 1e6 + text:append(string.format(" %.0fms", ms), "GlazeTime") + end + + text:nl() + + -- Show output for errors or running tasks + if task.status == "error" and #task.output > 0 then + for _, line in ipairs(task.output) do + text:append(line, "GlazeError", { indent = 6 }):nl() + end + end +end + +---@param text GlazeText +---@param binary GlazeBinary +---@param icons GlazeIcons +---@private +function M._render_binary(text, binary, icons) + local glaze = require("glaze") + local installed = glaze.is_installed(binary.name) + + local icon = installed and icons.done or icons.pending + local icon_hl = installed and "GlazeIconDone" or "GlazePending" + + text:append(" " .. icon .. " ", icon_hl) + text:append(binary.name, "GlazeBinary") + + if binary.plugin then + text:append(" (" .. binary.plugin .. ")", "GlazePlugin") + end + + text:nl() + text:append(binary.url, "GlazeUrl", { indent = 6 }):nl() +end + +---Close the UI. +function M.close() + if M._timer then + vim.fn.timer_stop(M._timer) + M._timer = nil + end + if M._float then + M._float:close() + M._float = nil + end +end + +return M