From 5f37fbfa837dfee7ecd30f388b271f4a71c0a9e0 Mon Sep 17 00:00:00 2001 From: Luke Kershaw <35707277+l-kershaw@users.noreply.github.com> Date: Fri, 10 Dec 2021 19:08:24 +0000 Subject: [PATCH] feat: layout `anchor` (#1582) * feat: add `anchor` option to some `layout_strategies` * tests: improve tests for `resolve_height/width` --- doc/telescope.txt | 29 ++++ lua/telescope/config/resolve.lua | 29 ++++ lua/telescope/pickers/layout_strategies.lua | 32 ++++- lua/tests/automated/resolver_spec.lua | 140 +++++++++++++++++++- 4 files changed, 223 insertions(+), 7 deletions(-) diff --git a/doc/telescope.txt b/doc/telescope.txt index eae3ebe..1991e45 100644 --- a/doc/telescope.txt +++ b/doc/telescope.txt @@ -1609,6 +1609,9 @@ layout_strategies.horizontal() *layout_strategies.horizontal()* └──────────────────────────────────────────────────┘ `picker.layout_config` shared options: + - anchor: + - Which edge/corner to pin the picker to + - See |resolver.resolve_anchor_pos()| - height: - How tall to make Telescope's entire layout - See |resolver.resolve_height()| @@ -1634,6 +1637,9 @@ layout_strategies.center() *layout_strategies.center()* remaining space above. Particularly useful for creating dropdown menus (see |telescope.themes| and |themes.get_dropdown()|`). + Note that the `anchor` option can only pin this layout to the left or right + edges. + ┌──────────────────────────────────────────────────┐ │ ┌────────────────────────────────────────┐ │ │ | Preview | │ @@ -1652,6 +1658,9 @@ layout_strategies.center() *layout_strategies.center()* └──────────────────────────────────────────────────┘ `picker.layout_config` shared options: + - anchor: + - Which edge/corner to pin the picker to + - See |resolver.resolve_anchor_pos()| - height: - How tall to make Telescope's entire layout - See |resolver.resolve_height()| @@ -1713,6 +1722,9 @@ layout_strategies.vertical() *layout_strategies.vertical()* └──────────────────────────────────────────────────┘ `picker.layout_config` shared options: + - anchor: + - Which edge/corner to pin the picker to + - See |resolver.resolve_anchor_pos()| - height: - How tall to make Telescope's entire layout - See |resolver.resolve_height()| @@ -1740,6 +1752,9 @@ layout_strategies.flex() *layout_strategies.flex()* `picker.layout_config` shared options: + - anchor: + - Which edge/corner to pin the picker to + - See |resolver.resolve_anchor_pos()| - height: - How tall to make Telescope's entire layout - See |resolver.resolve_height()| @@ -1812,6 +1827,20 @@ resolver.resolve_width() *resolver.resolve_width()* +resolver.resolve_anchor_pos() *resolver.resolve_anchor_pos()* + Calculates the adjustment required to move the picker from the middle of + the screen to an edge or corner. + The `anchor` can be any of the following strings: + - "", "CENTER", "NW", "N", "NE", "E", "SE", "S", "SW", "W" The anchors + have the following meanings: + - "" or "CENTER": + the picker will remain in the middle of the screen. + - Compass directions: + the picker will move to the corresponding edge/corner e.g. "NW" -> "top + left corner", "E" -> "right edge", "S" -> "bottom edge" + + + ================================================================================ *telescope.actions* diff --git a/lua/telescope/config/resolve.lua b/lua/telescope/config/resolve.lua index 9875565..3b47025 100644 --- a/lua/telescope/config/resolve.lua +++ b/lua/telescope/config/resolve.lua @@ -213,6 +213,35 @@ resolver.resolve_width = function(val) error("invalid configuration option for width:" .. tostring(val)) end +--- Calculates the adjustment required to move the picker from the middle of the screen to +--- an edge or corner.
+--- The `anchor` can be any of the following strings: +--- - "", "CENTER", "NW", "N", "NE", "E", "SE", "S", "SW", "W" +--- The anchors have the following meanings: +--- - "" or "CENTER":
+--- the picker will remain in the middle of the screen. +--- - Compass directions:
+--- the picker will move to the corresponding edge/corner +--- e.g. "NW" -> "top left corner", "E" -> "right edge", "S" -> "bottom edge" +resolver.resolve_anchor_pos = function(anchor, p_width, p_height, max_columns, max_lines) + anchor = anchor:upper() + local pos = { 0, 0 } + if anchor == "CENTER" then + return pos + end + if anchor:find "W" then + pos[1] = math.ceil((p_width - max_columns) / 2) + 1 + elseif anchor:find "E" then + pos[1] = math.ceil((max_columns - p_width) / 2) - 1 + end + if anchor:find "N" then + pos[2] = math.ceil((p_height - max_lines) / 2) + 1 + elseif anchor:find "S" then + pos[2] = math.ceil((max_lines - p_height) / 2) - 1 + end + return pos +end + -- Win option always returns a table with preview, results, and prompt. -- It handles many different ways. Some examples are as follows: -- diff --git a/lua/telescope/pickers/layout_strategies.lua b/lua/telescope/pickers/layout_strategies.lua index 9b1b2cd..7159797 100644 --- a/lua/telescope/pickers/layout_strategies.lua +++ b/lua/telescope/pickers/layout_strategies.lua @@ -107,6 +107,13 @@ local get_valid_configuration_keys = function(strategy_config) return valid_configuration_keys end +local adjust_pos = function(pos, ...) + for _, opts in ipairs { ... } do + opts.col = opts.col and opts.col + pos[1] + opts.line = opts.line and opts.line + pos[2] + end +end + --@param strategy_name string: the name of the layout_strategy we are validating for --@param configuration table: table with keys for each option available --@param values table: table containing all of the non-default options we want to set @@ -187,6 +194,7 @@ local shared_options = { mirror = "Flip the location of the results/prompt and preview windows", scroll_speed = "The number of lines to scroll through the previewer", prompt_position = { "Where to place prompt window.", "Available Values: 'bottom', 'top'" }, + anchor = { "Which edge/corner to pin the picker to", "See |resolver.resolve_anchor_pos()|" }, } -- Used for generating vim help documentation. @@ -368,6 +376,9 @@ layout_strategies.horizontal = make_documented_layout( error(string.format("Unknown prompt_position: %s\n%s", self.window.prompt_position, vim.inspect(layout_config))) end + local anchor_pos = resolve.resolve_anchor_pos(layout_config.anchor or "", width, height, max_columns, max_lines) + adjust_pos(anchor_pos, prompt, results, preview) + if tbln then prompt.line = prompt.line + 1 results.line = results.line + 1 @@ -388,6 +399,8 @@ layout_strategies.horizontal = make_documented_layout( --- Particularly useful for creating dropdown menus --- (see |telescope.themes| and |themes.get_dropdown()|`). --- +--- Note that the `anchor` option can only pin this layout to the left or right edges. +--- ---
 --- ┌──────────────────────────────────────────────────┐
 --- │    ┌────────────────────────────────────────┐    │
@@ -475,7 +488,12 @@ layout_strategies.center = make_documented_layout(
       preview.height = 0
     end
 
-    results.col, preview.col, prompt.col = 0, 0, 0 -- all centered
+    local width_padding = math.floor((max_columns - width) / 2) + bs + 1
+    results.col, preview.col, prompt.col = width_padding, width_padding, width_padding
+
+    local anchor_pos = resolve.resolve_anchor_pos(layout_config.anchor or "", width, height, max_columns, max_lines)
+    anchor_pos[2] = 0 -- only use horizontal anchoring
+    adjust_pos(anchor_pos, prompt, results, preview)
 
     if tbln then
       prompt.line = prompt.line + 1
@@ -514,7 +532,11 @@ layout_strategies.center = make_documented_layout(
 --- 
layout_strategies.cursor = make_documented_layout( "cursor", - vim.tbl_extend("error", shared_options, { + vim.tbl_extend("error", { + width = shared_options.width, + height = shared_options.height, + scroll_speed = shared_options.scroll_speed, + }, { preview_width = { "Change the width of Telescope's preview window", "See |resolver.resolve_width()|" }, preview_cutoff = "When columns are less than this value, the preview will be disabled", }), @@ -661,7 +683,8 @@ layout_strategies.vertical = make_documented_layout( prompt.height = 1 results.height = height - preview.height - prompt.height - h_space - results.col, preview.col, prompt.col = 0, 0, 0 -- all centered + local width_padding = math.floor((max_columns - width) / 2) + bs + 1 + results.col, preview.col, prompt.col = width_padding, width_padding, width_padding local height_padding = math.floor((max_lines - height) / 2) if not layout_config.mirror then @@ -689,6 +712,9 @@ layout_strategies.vertical = make_documented_layout( end end + local anchor_pos = resolve.resolve_anchor_pos(layout_config.anchor or "", width, height, max_columns, max_lines) + adjust_pos(anchor_pos, prompt, results, preview) + if tbln then prompt.line = prompt.line + 1 results.line = results.line + 1 diff --git a/lua/tests/automated/resolver_spec.lua b/lua/tests/automated/resolver_spec.lua index ac141bd..0251ec0 100644 --- a/lua/tests/automated/resolver_spec.lua +++ b/lua/tests/automated/resolver_spec.lua @@ -59,10 +59,142 @@ describe("telescope.config.resolve", function() end) describe("resolve_height/width", function() - eq(10, resolve.resolve_height(0.1)(nil, 24, 100)) - eq(2, resolve.resolve_width(0.1)(nil, 24, 100)) + local test_sizes = { + { 24, 100 }, + { 35, 125 }, + { 60, 59 }, + { 100, 40 }, + } + it("should handle percentages", function() + local percentages = { 0.1, 0.33333, 0.5, 0.99 } + for _, s in ipairs(test_sizes) do + for _, p in ipairs(percentages) do + eq(math.floor(s[1] * p), resolve.resolve_width(p)(nil, unpack(s))) + eq(math.floor(s[2] * p), resolve.resolve_height(p)(nil, unpack(s))) + end + end + end) - eq(10, resolve.resolve_width(10)(nil, 24, 100)) - eq(24, resolve.resolve_width(50)(nil, 24, 100)) + it("should handle fixed size", function() + local fixed = { 5, 8, 13, 21, 34 } + for _, s in ipairs(test_sizes) do + for _, f in ipairs(fixed) do + eq(math.min(f, s[1]), resolve.resolve_width(f)(nil, unpack(s))) + eq(math.min(f, s[2]), resolve.resolve_height(f)(nil, unpack(s))) + end + end + end) + + it("should handle functions", function() + local func = function(_, max_columns, max_lines) + if max_columns < 45 then + return math.min(max_columns, max_lines) + elseif max_columns < max_lines then + return max_columns * 0.8 + else + return math.min(max_columns, max_lines) * 0.5 + end + end + for _, s in ipairs(test_sizes) do + eq(func(nil, unpack(s)), resolve.resolve_height(func)(nil, unpack(s))) + end + end) + + it("should handle padding", function() + local func = function(_, max_columns, max_lines) + return math.floor(math.min(max_columns * 0.6, max_lines * 0.8)) + end + local pads = { 0.1, 5, func } + for _, s in ipairs(test_sizes) do + for _, p in ipairs(pads) do + eq(s[1] - 2 * resolve.resolve_width(p)(nil, unpack(s)), resolve.resolve_width { padding = p }(nil, unpack(s))) + eq( + s[2] - 2 * resolve.resolve_height(p)(nil, unpack(s)), + resolve.resolve_height { padding = p }(nil, unpack(s)) + ) + end + end + end) + end) + + describe("resolve_anchor_pos", function() + local test_sizes = { + { 6, 7, 8, 9 }, + { 10, 20, 30, 40 }, + { 15, 15, 16, 16 }, + { 17, 19, 23, 31 }, + { 21, 18, 26, 24 }, + { 50, 100, 150, 200 }, + } + + it([[should not adjust when "CENTER" or "" is the anchor]], function() + for _, s in ipairs(test_sizes) do + eq({ 0, 0 }, resolve.resolve_anchor_pos("", unpack(s))) + eq({ 0, 0 }, resolve.resolve_anchor_pos("center", unpack(s))) + eq({ 0, 0 }, resolve.resolve_anchor_pos("CENTER", unpack(s))) + end + end) + + it([[should end up at top when "N" in the anchor]], function() + local top_test = function(anchor, p_width, p_height, max_columns, max_lines) + local pos = resolve.resolve_anchor_pos(anchor, p_width, p_height, max_columns, max_lines) + eq(1, pos[2] + math.floor((max_lines - p_height) / 2)) + end + for _, s in ipairs(test_sizes) do + top_test("NW", unpack(s)) + top_test("N", unpack(s)) + top_test("NE", unpack(s)) + end + end) + + it([[should end up at left when "W" in the anchor]], function() + local left_test = function(anchor, p_width, p_height, max_columns, max_lines) + local pos = resolve.resolve_anchor_pos(anchor, p_width, p_height, max_columns, max_lines) + eq(1, pos[1] + math.floor((max_columns - p_width) / 2)) + end + for _, s in ipairs(test_sizes) do + left_test("NW", unpack(s)) + left_test("W", unpack(s)) + left_test("SW", unpack(s)) + end + end) + + it([[should end up at bottom when "S" in the anchor]], function() + local bot_test = function(anchor, p_width, p_height, max_columns, max_lines) + local pos = resolve.resolve_anchor_pos(anchor, p_width, p_height, max_columns, max_lines) + eq(max_lines - 1, pos[2] + p_height + math.floor((max_lines - p_height) / 2)) + end + for _, s in ipairs(test_sizes) do + bot_test("SW", unpack(s)) + bot_test("S", unpack(s)) + bot_test("SE", unpack(s)) + end + end) + + it([[should end up at right when "E" in the anchor]], function() + local right_test = function(anchor, p_width, p_height, max_columns, max_lines) + local pos = resolve.resolve_anchor_pos(anchor, p_width, p_height, max_columns, max_lines) + eq(max_columns - 1, pos[1] + p_width + math.floor((max_columns - p_width) / 2)) + end + for _, s in ipairs(test_sizes) do + right_test("NE", unpack(s)) + right_test("E", unpack(s)) + right_test("SE", unpack(s)) + end + end) + + it([[should ignore casing of the anchor]], function() + local case_test = function(a1, a2, p_width, p_height, max_columns, max_lines) + local pos1 = resolve.resolve_anchor_pos(a1, p_width, p_height, max_columns, max_lines) + local pos2 = resolve.resolve_anchor_pos(a2, p_width, p_height, max_columns, max_lines) + eq(pos1, pos2) + end + for _, s in ipairs(test_sizes) do + case_test("ne", "NE", unpack(s)) + case_test("w", "W", unpack(s)) + case_test("sW", "sw", unpack(s)) + case_test("cEnTeR", "CeNtEr", unpack(s)) + end + end) end) end)