diff --git a/lua/mason-core/spinner.lua b/lua/mason-core/spinner.lua new file mode 100644 index 0000000000..57f52c8911 --- /dev/null +++ b/lua/mason-core/spinner.lua @@ -0,0 +1,127 @@ +local EventEmitter = require "mason-core.EventEmitter" +local settings = require "mason.settings" +local uv = vim.uv + +local event = EventEmitter:new() + +---@class Spinner +---@field id string +---@field private enabled boolean +---@field private active integer +---@field private index integer +local Spinner = {} +Spinner.__index = Spinner + +---@type string[] +local texts = settings.current.ui.icons.spinner_texts + +---@type integer +local texts_length = #texts + +---@type uv.uv_timer_t|nil +local timer = nil + +---@type table +local ticks = {} + +---@type integer spinner refresh interval +local INTERVAL = 80 + +local function start_tick(id, cb) + if not ticks[id] then + ticks[id] = cb + end + + if timer then + return + end + + timer = uv.new_timer() + assert(timer, "Failed to create spinner timer") + timer:start( + 0, + INTERVAL, + vim.schedule_wrap(function() + vim.iter(ticks):each(function(_, f) + if f then + f() + end + end) + -- combine all spinners refresh event into one. + event:emit "change" + end) + ) +end + +local function stop_tick(id) + ticks[id] = nil + if next(ticks) == nil and timer then + timer:stop() + timer:close() + timer = nil + end +end + +---Create a new spinner. +--- +---@return Spinner +local function new(id) + return setmetatable({ + id = id, + enabled = false, + active = 0, + index = 1, + }, Spinner) +end + +---Start spinner. +function Spinner:start() + self.active = self.active + 1 + if self.enabled then + return + end + + self.enabled = true + --- refresh ui immediately. + event:emit "change" + + start_tick(self.id, function() + self.index = (self.index % texts_length) + 1 + end) +end + +---Stop spinner. +function Spinner:stop() + self.active = self.active - 1 + if not self.enabled or self.active > 0 then + return + end + + stop_tick(self.id) + + self.enabled = false + self.active = 0 + + event:emit "change" +end + +function Spinner:__tostring() + return self.enabled and texts[self.index] or "" +end + +---@type table +local instances = setmetatable({}, { + __index = function(self, k) + local v = rawget(self, k) + if v == nil then + v = new(k) + rawset(self, k, v) + end + return v + end, +}) + +return { + event = event, + instances = instances, +} diff --git a/lua/mason/settings.lua b/lua/mason/settings.lua index ebff1e0b44..6fc613b7b5 100644 --- a/lua/mason/settings.lua +++ b/lua/mason/settings.lua @@ -104,6 +104,9 @@ local DEFAULT_SETTINGS = { ---@since 1.0.0 -- The list icon to use for packages that are not installed. package_uninstalled = "◍", + ---@since 2.2.2 + ---Spinner frame texts + spinner_texts = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }, }, keymaps = { diff --git a/lua/mason/ui/components/main/package_list.lua b/lua/mason/ui/components/main/package_list.lua index 11f0d1743e..9dbe6eebea 100644 --- a/lua/mason/ui/components/main/package_list.lua +++ b/lua/mason/ui/components/main/package_list.lua @@ -2,6 +2,7 @@ local Ui = require "mason-core.ui" local _ = require "mason-core.functional" local p = require "mason.ui.palette" local settings = require "mason.settings" +local spinner = require "mason-core.spinner" local JsonSchema = require "mason.ui.components.json-schema" @@ -205,15 +206,14 @@ local function Installed(state) local registry_count = #state.info.registries local text if registry_count > 1 then - text = p.Comment( - is_all_registries_installed and ("updating %d registries "):format(registry_count) - or ("installing %d registries "):format(registry_count) - ) + text = is_all_registries_installed and ("updating %d registries "):format(registry_count) + or ("installing %d registries "):format(registry_count) else - text = p.Comment(is_all_registries_installed and "updating registry " or "installing registry ") + text = is_all_registries_installed and "updating registry " or "installing registry " end + text = string.format("%s%s ", text, spinner.instances.registries) return Ui.VirtualTextNode { - text, + p.Comment(text), styling( ("%-4s"):format(math.floor(state.info.registry_update.percentage_complete * 100) .. "%") ), @@ -270,7 +270,7 @@ local function InstallingPackageComponent(pkg, state) { pkg_state.has_failed and p.error(settings.current.ui.icons.package_uninstalled) or p.highlight(settings.current.ui.icons.package_pending), - p.none(" " .. pkg.name), + p.none(string.format(" %s %s", pkg.name, spinner.instances[pkg.name])), current_state, pkg_state.latest_spawn and p.Comment((" $ %s"):format(pkg_state.latest_spawn)) or p.none "", }, diff --git a/lua/mason/ui/instance.lua b/lua/mason/ui/instance.lua index 476bdf8c85..5fe0b51d04 100644 --- a/lua/mason/ui/instance.lua +++ b/lua/mason/ui/instance.lua @@ -9,6 +9,7 @@ local display = require "mason-core.ui.display" local notify = require "mason-core.notify" local registry = require "mason-registry" local settings = require "mason.settings" +local spinner = require "mason-core.spinner" local Header = require "mason.ui.components.header" local Help = require "mason.ui.components.help" @@ -229,8 +230,10 @@ local function setup_handle(handle) if handle.state == "QUEUED" then mutate_package_grouping(handle.package, "queued", true) elseif handle.state == "ACTIVE" then + spinner.instances[handle.package.name]:start() mutate_package_grouping(handle.package, "installing", true) elseif handle.state == "CLOSED" then + spinner.instances[handle.package.name]:stop() mutate_state(function(state) state.packages.states[handle.package.name].is_terminated = false end) @@ -605,6 +608,7 @@ local function setup_package(pkg) pkg:on("install:handle", setup_handle) pkg:on("install:success", function() + spinner.instances[pkg.name]:stop() vim.schedule(function() notify(("%s was successfully installed."):format(pkg.name)) end) @@ -621,6 +625,7 @@ local function setup_package(pkg) "install:failed", ---@param handle InstallHandle function(handle) + spinner.instances[pkg.name]:stop() if handle.is_terminated then -- If installation was explicitly terminated - restore to "pristine" state mutate_state(function(state) @@ -682,6 +687,7 @@ local function setup_packages(packages) end registry:on("update:failed", function(errors) + spinner.instances.registries:stop() mutate_state(function(state) state.info.registry_update.percentage_complete = 0 state.info.registry_update.in_progress = false @@ -690,6 +696,7 @@ registry:on("update:failed", function(errors) end) registry:on("update:success", function() + spinner.instances.registries:stop() setup_packages(registry.get_all_packages()) update_registry_info() check_new_package_versions() @@ -708,6 +715,7 @@ registry:on("update:success", function() end) registry:on("update:start", function() + spinner.instances.registries:start() mutate_state(function(state) state.packages.outdated_packages = {} state.info.registry_update.error = nil @@ -716,6 +724,11 @@ registry:on("update:start", function() end) end) +spinner.event:on("change", function() + -- just refresh UI, nothing change. + mutate_state(function(state) end) +end) + registry:on("update:progress", function(finished, all) mutate_state(function(state) state.info.registry_update.percentage_complete = #finished / #all diff --git a/tests/mason-core/spinner_spec.lua b/tests/mason-core/spinner_spec.lua new file mode 100644 index 0000000000..c8de4316f8 --- /dev/null +++ b/tests/mason-core/spinner_spec.lua @@ -0,0 +1,66 @@ +local spinner = require "mason-core.spinner" +local stub = require "luassert.stub" +local eq = assert.are.same + +---@diagnostic disable: invisible +---@diagnostic disable: undefined-field + +describe("spinner", function() + before_each(function() + spinner.instances.sp = nil + stub(vim.uv, "new_timer").returns { + start = function(_, _, _, callback) + callback() + end, + stop = function(_) end, + close = function(_) end, + } + stub(vim, "schedule_wrap").invokes(function(fn) + return fn + end) + end) + + it("empty default", function() + local sp = spinner.instances.sp + eq(false, sp.enabled) + eq("", tostring(sp)) + end) + + it("start spinner", function() + local sp = spinner.instances.sp + stub(vim.uv, "new_timer").returns { + start = function(_, _, _, callback) + callback() + eq(true, sp.enabled) + eq(true, tostring(sp) ~= "") + + callback() + eq(true, sp.enabled) + eq(true, tostring(sp) ~= "") + end, + stop = function(_) end, + close = function(_) end, + } + sp:start() + end) + + it("remain start if call times start() > stop()", function() + local sp = spinner.instances.sp + + sp:start() + eq(true, sp.enabled) + sp:start() + eq(true, sp.enabled) + + sp:stop() + -- remain enable + eq(true, sp.enabled) + + sp:stop() + eq(false, sp.enabled) + + -- remian stop + sp:stop() + eq(false, sp.enabled) + end) +end)