diff --git a/README.md b/README.md index cd2474f..109a68e 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ require("wmc").setup({ }) ``` -| Option | Explanation | +| Option | Explanation | |-|-| | enabled | Toggle `wmc` | -| ui | Configure style rank display window (partially derived from `vim.api.keyset.win_config`). `row` and `col` support passing a function for dynamic Neovim window resize handling | -| logger | Toggle debug logging and change log destination | +| ui | Configure style rank display window (partially derived from `vim.api.keyset.win_config`). `row` and `col` support passing a function for dynamic Neovim window resize handling. `progress_bar` configures rank progression visualization | +| logger | Toggle debug logging and change log destination | ## Development diff --git a/lua/wmc/config.lua b/lua/wmc/config.lua index d018379..8a20577 100644 --- a/lua/wmc/config.lua +++ b/lua/wmc/config.lua @@ -15,6 +15,11 @@ local M = {} ---@field col number|fun(): number ---@field zindex number ---@field border string +---@field progress_bar wmc.config.ui.progress_bar + +---@class (exact) wmc.config.ui.progress_bar +---@field enabled boolean +---@field extra_width integer ---@type wmc.config M.default = { @@ -27,6 +32,10 @@ M.default = { end, zindex = 50, border = "none", + progress_bar = { + enabled = false, + extra_width = 4, + }, }, logger = { enabled = false, @@ -63,6 +72,10 @@ function M.validate(config) M.validate_entry({ zindex = { config.ui.zindex, "number", true } }, validation_errors) M.validate_entry({ border = { config.ui.border, { "string", "table" }, true } }, validation_errors) + M.validate_entry({ progress_bar = { config.ui.progress_bar, "table", true } }, validation_errors) + M.validate_entry({ enabled = { config.ui.progress_bar.enabled, "boolean", true } }, validation_errors) + M.validate_entry({ padding = { config.ui.progress_bar.extra_width, "number", true } }, validation_errors) + M.validate_entry({ logger = { config.logger, "table", true } }, validation_errors) M.validate_entry({ enabled = { config.logger.enabled, "boolean", true } }, validation_errors) M.validate_entry({ log_file_path = { config.logger.log_file_path, "string", true } }, validation_errors) diff --git a/lua/wmc/constants.lua b/lua/wmc/constants.lua index ed42f33..a47db02 100644 --- a/lua/wmc/constants.lua +++ b/lua/wmc/constants.lua @@ -11,6 +11,34 @@ M.RANK_LABEL = { STYLISH = "Stylish!", } +---@class wmc.rank_score +---@field min_score integer +---@field max_score integer|nil + +---@type table +M.RANK_SCORE = { + [M.RANK_LABEL.DULL] = { + min_score = 20, + max_score = 110, + }, + [M.RANK_LABEL.COOL] = { + min_score = 110, + max_score = 200, + }, + [M.RANK_LABEL.BRAVO] = { + min_score = 200, + max_score = 300, + }, + [M.RANK_LABEL.ABSOLUTE] = { + min_score = 300, + max_score = 390, + }, + [M.RANK_LABEL.STYLISH] = { + min_score = 390, + max_score = nil, + }, +} + ---@class wmc.rank ---@field label wmc.rank_label ---@field min_score integer @@ -19,19 +47,19 @@ M.RANK_LABEL = { M.RANK_PROGRESSION = { [M.RANK_LABEL.DULL] = { label = M.RANK_LABEL.COOL, - min_score = 110, + min_score = M.RANK_SCORE[M.RANK_LABEL.COOL].min_score, }, [M.RANK_LABEL.COOL] = { label = M.RANK_LABEL.BRAVO, - min_score = 200, + min_score = M.RANK_SCORE[M.RANK_LABEL.BRAVO].min_score, }, [M.RANK_LABEL.BRAVO] = { label = M.RANK_LABEL.ABSOLUTE, - min_score = 300, + min_score = M.RANK_SCORE[M.RANK_LABEL.ABSOLUTE].min_score, }, [M.RANK_LABEL.ABSOLUTE] = { label = M.RANK_LABEL.STYLISH, - min_score = 390, + min_score = M.RANK_SCORE[M.RANK_LABEL.STYLISH].min_score, }, } diff --git a/lua/wmc/ranker.lua b/lua/wmc/ranker.lua index 031d666..5dd2d91 100644 --- a/lua/wmc/ranker.lua +++ b/lua/wmc/ranker.lua @@ -1,3 +1,4 @@ +local config = require("wmc.config") local constants = require("wmc.constants") local logger = require("wmc.logger") local timer = require("wmc.utils.timer") @@ -7,7 +8,8 @@ local ui = require("wmc.ui") ---@class wmc.ranker ---@field private combo wmc.ring_buffer ---@field private is_combo_valid boolean ----@field private rank wmc.rank_label|nil +---@field private rank_label wmc.rank_label|nil +---@field private rank_progression number|nil ---@field private last_input_sec number ---@field private watcher uv.uv_timer_t ---@field private ui wmc.ui @@ -38,7 +40,8 @@ function M:start_combo_watcher() self.combo:clear() self.is_combo_valid = true self.last_input_sec = 0 - self.rank = nil + self.rank_label = nil + self.rank_progression = nil self.ui:clear() @@ -56,7 +59,7 @@ function M:start_combo_watcher() end ---@return fun(key: string, typed: string): string? -function M:on_key() +function M:on_key_entered() return function(key, typed) self.last_input_sec = 0 @@ -86,16 +89,17 @@ function M:on_key() if not self.is_combo_valid then local invalid_rank = constants.RANK_LABEL.DULL - if invalid_rank ~= self.rank then - self.ui:render(invalid_rank) + if invalid_rank ~= self.rank_label or self.rank_progression then + self.ui:render(invalid_rank, nil) end - self.rank = invalid_rank + self.rank_label = invalid_rank + self.rank_progression = nil logger.log( ("Length: %d, Rank - %s (Invalid)\n%s"):format( self.combo:get_length(), - self.rank, + self.rank_label, vim.fn.keytrans(self.combo:to_string()) ) ) @@ -107,12 +111,17 @@ function M:on_key() local combo_score = self:get_combo_score(combo_entropy) local new_rank = self:get_display_rank(combo_entropy, combo_score) + local rank_progression = new_rank + and config.options.ui.progress_bar.enabled + and self.get_rank_progression(new_rank, combo_score) + or nil - if new_rank and new_rank ~= self.rank then - self.ui:render(new_rank) + if new_rank and (new_rank ~= self.rank_label or rank_progression ~= self.rank_progression) then + self.ui:render(new_rank, rank_progression) end - self.rank = new_rank + self.rank_label = new_rank + self.rank_progression = rank_progression logger.log( ("%s: %d, %s: %f, %s: %f, %s: %s\n%s"):format( @@ -123,7 +132,7 @@ function M:on_key() "Score", combo_score, "Rank", - self.rank, + self.rank_label, vim.fn.keytrans(self.combo:to_string()) ) ) @@ -154,28 +163,46 @@ function M:get_combo_entropy() return entropy end +---@private +---@param combo_entropy number +---@return number +function M:get_combo_score(combo_entropy) + return math.pow(combo_entropy, 2) * self.combo:get_length() +end + ---@private ---@param combo_entropy number ---@param combo_score number ---@return wmc.rank_label|nil function M:get_display_rank(combo_entropy, combo_score) - if not self.rank and combo_entropy > 1.5 then + if not self.rank_label and combo_entropy > 1.5 then return constants.RANK_LABEL.DULL end - local next_rank = constants.RANK_PROGRESSION[self.rank] + local next_rank = constants.RANK_PROGRESSION[self.rank_label] if not next_rank or next_rank.min_score > combo_score then - return self.rank + return self.rank_label end return next_rank.label end ---@private ----@param combo_entropy number ----@return number -function M:get_combo_score(combo_entropy) - return math.pow(combo_entropy, 2) * self.combo:get_length() +---@param combo_rank wmc.rank_label +---@param combo_score number +---@return number|nil +function M.get_rank_progression(combo_rank, combo_score) + if not combo_rank then + return nil + end + + local rank_score_bounds = constants.RANK_SCORE[combo_rank] + if not rank_score_bounds.max_score then + return nil + end + + return math.max(combo_score - rank_score_bounds.min_score, 0) + / (rank_score_bounds.max_score - rank_score_bounds.min_score) end return M diff --git a/lua/wmc/ui.lua b/lua/wmc/ui.lua index d1aa54d..9226cbc 100644 --- a/lua/wmc/ui.lua +++ b/lua/wmc/ui.lua @@ -1,3 +1,4 @@ +local config = require("wmc.config") local constants = require("wmc.constants") local find_max_length = require("wmc.utils.string").find_max_length @@ -7,6 +8,7 @@ local find_max_length = require("wmc.utils.string").find_max_length ---@field private extmark_id integer ---@field private max_rendered_text_length integer ---@field private last_rendered_text string|nil +---@field private last_renderered_progress number|nil local M = {} ---@type table @@ -75,17 +77,18 @@ function M:open_window() relative = "editor", anchor = "NE", width = 1, - height = 1, + height = config.options.ui.progress_bar.enabled and 2 or 1, zindex = 50, style = "minimal", border = "none", focusable = false, noautocmd = true, - }, require("wmc.config").options.ui) + }, config.options.ui) -- Override functions passed in config for the initial hidden window window_config.row = 1 window_config.col = 1 + window_config.progress_bar = nil self.window_id = vim.api.nvim_open_win(self.buffer_id, false, window_config) if self.window_id == 0 then @@ -111,24 +114,35 @@ function M:reopen_window() self:open_window() if self.last_rendered_text then - self:render(self.last_rendered_text) + self:render(self.last_rendered_text, self.last_renderered_progress) end end ---@param rank_label wmc.rank_label -function M:render(rank_label) +---@param rank_progress number|nil +function M:render(rank_label, rank_progress) vim.schedule(function() if not self:is_window_active() or not self:is_buffer_active() then return end - local ui_config = require("wmc.config").options.ui + local label_color = M.RANK_HL_GROUP_NAME[rank_label] + local width = config.options.ui.progress_bar.enabled + and self.max_rendered_text_length + config.options.ui.progress_bar.extra_width + or #rank_label local extmark_options = { - virt_text = { { rank_label, M.RANK_HL_GROUP_NAME[rank_label] } }, - virt_text_win_col = 0, + virt_text = { { rank_label, label_color } }, + virt_text_win_col = config.options.ui.progress_bar.enabled and math.floor((width / 2) - (#rank_label / 2)) + or 0, } + if config.options.ui.progress_bar.enabled and rank_progress then + extmark_options.virt_lines = { + { { self.prepare_progress_bar(rank_progress, width), label_color } }, + } + end + if self.extmark_id then extmark_options.id = self.extmark_id end @@ -136,16 +150,29 @@ function M:render(rank_label) vim.api.nvim_win_set_config(self.window_id, { hide = false, relative = "editor", - row = self.resolve_window_position(ui_config.row), - col = self.resolve_window_position(ui_config.col) - (self.max_rendered_text_length - #rank_label), - width = #rank_label, + row = self.resolve_window_position(config.options.ui.row), + col = config.options.ui.progress_bar.enabled and self.resolve_window_position(config.options.ui.col) + or self.resolve_window_position(config.options.ui.col) + - (self.max_rendered_text_length - #rank_label), + width = width, }) self.extmark_id = vim.api.nvim_buf_set_extmark(self.buffer_id, vim.g.wmc_namespace_id, 0, 0, extmark_options) self.last_rendered_text = rank_label + self.last_renderered_progress = rank_progress end) end +---@private +---@param progress number +---@param width number +---@return string +function M.prepare_progress_bar(progress, width) + local completion_width = math.floor(progress * width + 0.5) + + return ("█"):rep(completion_width) .. ("░"):rep(width - completion_width) +end + function M:clear() vim.schedule(function() if not self:is_window_active() or not self:is_buffer_active() then @@ -153,6 +180,7 @@ function M:clear() end self.last_rendered_text = nil + self.last_renderered_progress = nil vim.api.nvim_win_set_config(self.window_id, { hide = true, diff --git a/plugin/wmc.lua b/plugin/wmc.lua index fc4b759..e0b1bca 100644 --- a/plugin/wmc.lua +++ b/plugin/wmc.lua @@ -7,5 +7,5 @@ vim.schedule(function() return end - vim.on_key(require("wmc.ranker"):new():on_key(), vim.g.wmc_namespace_id) + vim.on_key(require("wmc.ranker"):new():on_key_entered(), vim.g.wmc_namespace_id) end) diff --git a/test/ranker_spec.lua b/test/ranker_spec.lua index 11c7d6e..4d03ece 100644 --- a/test/ranker_spec.lua +++ b/test/ranker_spec.lua @@ -1,6 +1,7 @@ describe("ranker", function() local stub = require("luassert.stub") local ui_stub = require("wmc.ui") + local config = require("wmc.config") local constants = require("wmc.constants") local ranker @@ -12,7 +13,7 @@ describe("ranker", function() end local function type_combo(combo) - local handler = ranker:on_key() + local handler = ranker:on_key_entered() for i = 1, #combo do local key = combo:sub(i, i) @@ -40,11 +41,11 @@ describe("ranker", function() ui_stub.new:revert() end) - describe("on_key", function() + describe("on_key_entered", function() it("should assign a rank", function() type_combo("jackpot") - assert.are.equal(constants.RANK_LABEL.DULL, ranker.rank) + assert.are.equal(constants.RANK_LABEL.DULL, ranker.rank_label) end) it("should reset idle counter on input", function() @@ -58,7 +59,7 @@ describe("ranker", function() it("should advance rank", function() type_combo("jackpotjackpot") - assert.are.equal(constants.RANK_LABEL.COOL, ranker.rank) + assert.are.equal(constants.RANK_LABEL.COOL, ranker.rank_label) end) it("should not advance rank for insert mode input", function() @@ -69,7 +70,7 @@ describe("ranker", function() type_combo("jackpot") - assert.is_nil(ranker.rank) + assert.is_nil(ranker.rank_label) assert.are.equal(0, ranker.combo:get_length()) assert.are.equal(0, ranker.last_input_sec) end) @@ -77,22 +78,43 @@ describe("ranker", function() it("should invalidate rank on mouse usage", function() local mouse_input = vim.api.nvim_replace_termcodes("", true, false, true) - ranker:on_key()(mouse_input, mouse_input) + ranker:on_key_entered()(mouse_input, mouse_input) - assert.are.equal(constants.RANK_LABEL.DULL, ranker.rank) + assert.are.equal(constants.RANK_LABEL.DULL, ranker.rank_label) assert.is_false(ranker.is_combo_valid) type_combo("jackpotjackpot") - assert.are.equal(constants.RANK_LABEL.DULL, ranker.rank) + assert.are.equal(constants.RANK_LABEL.DULL, ranker.rank_label) assert.is_false(ranker.is_combo_valid) end) + it("should re-render only when rank or progression changes", function() + config.options.ui.progress_bar.enabled = true + + local rank_progressions = { 0.1, 0.1, 0.2, 0.2, 0.2 } + stub(ranker, "get_rank_progression").invokes(function() + return table.remove(rank_progressions, 1) + end) + + local render_stub = stub(ranker.ui, "render") + finally(function() + config.options.ui.progress_bar.enabled = false + render_stub:revert() + end) + + type_combo("jackpot") + + assert.stub(render_stub).was.called(2) + assert.stub(render_stub).was.called_with(ranker.ui, constants.RANK_LABEL.DULL, 0.1) + assert.stub(render_stub).was.called_with(ranker.ui, constants.RANK_LABEL.DULL, 0.2) + end) + it("should clear rank after inactivity", function() type_combo("jackpot") vim.wait(2000) - assert.is_nil(ranker.rank) + assert.is_nil(ranker.rank_label) assert.are.equal(0, ranker.combo:get_length()) end) end) @@ -120,4 +142,22 @@ describe("ranker", function() assert.are.near(2.8074, ranker:get_combo_entropy(), 1e-4) end) end) + + describe("get_rank_progression", function() + it("should return nil for a missing rank", function() + assert.is_nil(ranker.get_rank_progression(nil, 100)) + end) + + it("should return nil for the top rank", function() + assert.is_nil(ranker.get_rank_progression(constants.RANK_LABEL.STYLISH, 500)) + end) + + it("should return 0 for score below the rank minimum", function() + assert.are.equal(0, ranker.get_rank_progression(constants.RANK_LABEL.DULL, 0)) + end) + + it("should return progression within rank score bounds", function() + assert.are.equal(0.5, ranker.get_rank_progression(constants.RANK_LABEL.COOL, 155)) + end) + end) end)