Skip to content

Commit 86fa5cc

Browse files
committed
feat: configure west gutter position
1 parent 344339a commit 86fa5cc

11 files changed

Lines changed: 151 additions & 31 deletions

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ require("scrollbar").setup({
2727
row = 0,
2828
col = 0,
2929
gutter = "avoid", -- "avoid" or "overlap"
30+
gutter_position = "inner", -- "inner" or "outer"
3031
},
3132
},
3233
layout = {
@@ -168,6 +169,7 @@ width belongs to the layout, not the provider.
168169
- `float.placement.relative`: `"window"` or `"editor"`.
169170
- `float.placement.anchor`: `"NW"`, `"NE"`, `"SW"`, or `"SE"`.
170171
- `float.placement.gutter`: `"avoid"` or `"overlap"`.
172+
- `float.placement.gutter_position`: `"inner"` or `"outer"`.
171173
- `float.placement.row` and `col`: signed integers.
172174
- `thumb.text`: one positive-display-width string without control characters.
173175
- Exclusion options: dense string lists.

docs/layout-and-geometry.md

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ ordered prefix remains and the tail is omitted. Custom providers emitting
8282
window. `"editor"` uses editor coordinates and renders only the active source
8383
window. `anchor` selects `NW`, `NE`, `SW`, or `SE`; signed `row` and `col`
8484
offsets are applied literally. `gutter` selects `"avoid"` (the default) or
85-
`"overlap"`.
85+
`"overlap"`. `gutter_position` selects `"inner"` (the default, beside buffer
86+
text) or `"outer"` (at the source-window edge) within an avoided west gutter.
8687

8788
Gutter placement applies as follows:
8889

@@ -94,20 +95,22 @@ Gutter placement applies as follows:
9495

9596
For a window-relative west anchor, let `G` be the source's gutter width before
9697
the scrollbar reservation, `W` the rendered float width, and `C` the configured
97-
`col`. In `avoid` mode the renderer reserves `max(0, W + C)` blank cells at the
98-
text edge and places the float at `G + C`. With the default `col = 0`, the float
99-
occupies only those new cells and its right edge meets the shifted buffer-text
100-
edge. A positive offset leaves a gap before the float. A negative offset remains
101-
literal and may deliberately overlap existing gutter cells.
98+
`col`. In `avoid` mode the renderer reserves `max(0, W + C)` blank cells. With
99+
`gutter_position = "inner"`, the reservation follows the existing gutter and
100+
the float starts at `G + C`, beside the shifted buffer-text edge. With
101+
`gutter_position = "outer"`, the reservation precedes the existing gutter and
102+
the float starts at `C`, beside the source-window edge. A positive offset leaves
103+
a gap before the float within its reservation. A negative offset remains
104+
literal and may deliberately extend outside or overlap adjacent gutter cells.
102105

103106
The reservation temporarily wraps the window-local `statuscolumn` and expands
104107
its available width. Existing fold, sign, number, custom-format, and `%!`
105-
content remains before the reserved blank cells. The original `statuscolumn`
106-
and `numberwidth` are restored when the scrollbar is hidden, disposed, becomes
107-
ineligible, or switches to an unaffected placement. An external edit made while
108-
the reservation is active is preserved instead of overwritten. Each split owns
109-
and restores its reservation independently, including splits created from an
110-
already reserved window.
108+
content remains intact before an `"inner"` reservation or after an `"outer"`
109+
reservation. The original `statuscolumn` and `numberwidth` are restored when the
110+
scrollbar is hidden, disposed, becomes ineligible, or switches to an unaffected
111+
placement. An external edit made while the reservation is active is preserved
112+
instead of overwritten. Each split owns and restores its reservation
113+
independently, including splits created from an already reserved window.
111114

112115
Neovim limits the maximum statuscolumn width. If a declared west layout is too
113116
wide to reserve completely, the renderer omits that scrollbar instead of

docs/presets.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ require("scrollbar").setup({ preset = "review" })
2929
- `gvim`: a solid two-column conventional scrollbar without annotations.
3030
- `eclipse`: a bar plus diagnostic, navigation, and catch-all annotation lanes.
3131
- `sublime`: a trackless translucent thumb with all marks above it.
32-
- `emacs`: a west-anchored bar in a dedicated gutter reservation, with an
33-
inward diagnostic fringe.
32+
- `emacs`: a west-anchored bar at the outer edge of a dedicated gutter
33+
reservation, with an inward diagnostic fringe.
3434
- `xcode`: a compact bar with navigation and diagnostic stripes.
3535

3636
Editor-named presets approximate scrollbar and annotation structure in terminal
@@ -129,7 +129,7 @@ layout = {
129129
layout = { direction = "auto", columns = { { "thumb", "marks" } } }
130130

131131
-- emacs
132-
float = { placement = { anchor = "NW", gutter = "avoid" } }
132+
float = { placement = { anchor = "NW", gutter = "avoid", gutter_position = "outer" } }
133133
layout = {
134134
direction = "auto",
135135
columns = {

lua/scrollbar/config.lua

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ local DEFAULTS = {
2424
row = 0,
2525
col = 0,
2626
gutter = "avoid",
27+
gutter_position = "inner",
2728
},
2829
},
2930
layout = {
@@ -197,7 +198,14 @@ local NESTED_KEYS = {
197198
autohide = { enabled = true, delay_ms = true },
198199
render = { interval_ms = true, geometry = true },
199200
float = { zindex = true, hide_on_cursor = true, placement = true },
200-
["float.placement"] = { relative = true, anchor = true, row = true, col = true, gutter = true },
201+
["float.placement"] = {
202+
relative = true,
203+
anchor = true,
204+
row = true,
205+
col = true,
206+
gutter = true,
207+
gutter_position = true,
208+
},
201209
layout = { direction = true, columns = true },
202210
track = { highlight = true },
203211
mouse = { enabled = true },
@@ -260,6 +268,7 @@ local ENUMS = {
260268
relative = { window = true, editor = true },
261269
anchor = { NW = true, NE = true, SW = true, SE = true },
262270
gutter = { avoid = true, overlap = true },
271+
gutter_position = { inner = true, outer = true },
263272
direction = { auto = true, ltr = true, rtl = true },
264273
search_backend = { sync = true, worker = true },
265274
}
@@ -822,6 +831,7 @@ local function normalize(overrides)
822831
validate_enum(result.float.placement.relative, "float.placement.relative", ENUMS.relative)
823832
validate_enum(result.float.placement.anchor, "float.placement.anchor", ENUMS.anchor)
824833
validate_enum(result.float.placement.gutter, "float.placement.gutter", ENUMS.gutter)
834+
validate_enum(result.float.placement.gutter_position, "float.placement.gutter_position", ENUMS.gutter_position)
825835
if not is_integer(result.float.placement.row) then
826836
invalid("float.placement.row must be an integer")
827837
end

lua/scrollbar/presets.lua

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ local BUILTINS = {
112112
thumb = { blend = 60 },
113113
},
114114
emacs = {
115-
float = { placement = { anchor = "NW", gutter = "avoid" } },
115+
float = { placement = { anchor = "NW", gutter = "avoid", gutter_position = "outer" } },
116116
layout = {
117117
direction = "auto",
118118
columns = {
@@ -149,7 +149,14 @@ local NESTED_FIELDS = {
149149
track = { highlight = true },
150150
thumb = { text = true, blend = true, highlight = true, hide_if_all_visible = true },
151151
mark = { text = true, priority = true, highlight = true },
152-
placement = { relative = true, anchor = true, row = true, col = true, gutter = true },
152+
placement = {
153+
relative = true,
154+
anchor = true,
155+
row = true,
156+
col = true,
157+
gutter = true,
158+
gutter_position = true,
159+
},
153160
layer = { kind = true, types = true, max_width = true },
154161
}
155162

lua/scrollbar/renderer.lua

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ local STATUSCOLUMN_WRAPPER_PATTERN = '^%%!v:lua%.require%("scrollbar%.renderer"%
1717
---@field applied_numberwidth integer
1818
---@field span integer
1919
---@field base_textoff integer
20+
---@field position ScrollbarGutterPosition
2021
---@field limited boolean
2122
---@field failed boolean
2223

@@ -178,8 +179,9 @@ end
178179

179180
---@param source_win integer
180181
---@param span integer
182+
---@param position ScrollbarGutterPosition
181183
---@return ScrollbarStatuscolumnReservation?
182-
local function ensure_statuscolumn(source_win, span)
184+
local function ensure_statuscolumn(source_win, span, position)
183185
restore_inherited_statuscolumn(source_win)
184186
if span <= 0 then
185187
release_statuscolumn(source_win)
@@ -194,6 +196,7 @@ local function ensure_statuscolumn(source_win, span)
194196
if
195197
current_statuscolumn ~= reservation.applied_statuscolumn
196198
or current_numberwidth ~= reservation.applied_numberwidth
199+
or reservation.position ~= position
197200
then
198201
release_statuscolumn(source_win)
199202
reservation = nil
@@ -224,6 +227,7 @@ local function ensure_statuscolumn(source_win, span)
224227
applied_numberwidth = original_numberwidth + span,
225228
span = span,
226229
base_textoff = vim.fn.getwininfo(source_win)[1].textoff,
230+
position = position,
227231
limited = false,
228232
failed = false,
229233
}
@@ -317,7 +321,8 @@ M._statuscolumn = function(owner_win)
317321
value = type(evaluated) == "string" and evaluated or vim.fn.string(evaluated)
318322
end
319323
if drawn_win == owner_win then
320-
value = value .. string.rep(" ", reservation.span)
324+
local gap = string.rep(" ", reservation.span)
325+
value = reservation.position == "outer" and gap .. value or value .. gap
321326
end
322327
return value
323328
end
@@ -540,7 +545,10 @@ local function float_config(active_config, source_win, area, container_width, wi
540545
local placement = active_config.float.placement
541546
local north = placement.anchor == "NW" or placement.anchor == "NE"
542547
local west = placement.anchor == "NW" or placement.anchor == "SW"
543-
local west_origin = reserves_statuscolumn(active_config) and reservation_base_textoff(source_win) or 0
548+
local west_origin = 0
549+
if reserves_statuscolumn(active_config) and placement.gutter_position == "inner" then
550+
west_origin = reservation_base_textoff(source_win)
551+
end
544552
local vertical_anchor
545553
if placement.relative == "window" then
546554
vertical_anchor = north and 0 or area.height
@@ -1020,7 +1028,10 @@ local function render_source(source_win, selection, root_config)
10201028
if reserved then
10211029
local initial_width = existing_state and existing_state.width or active_config.layout.width
10221030
local initial_span = math.max(0, initial_width + active_config.float.placement.col)
1023-
if initial_span > 0 and ensure_statuscolumn(source_win, initial_span) == nil then
1031+
if
1032+
initial_span > 0
1033+
and ensure_statuscolumn(source_win, initial_span, active_config.float.placement.gutter_position) == nil
1034+
then
10241035
close_source(source_win)
10251036
return nil
10261037
end
@@ -1096,7 +1107,7 @@ local function render_source(source_win, selection, root_config)
10961107
if span == (current and current.span or 0) then
10971108
break
10981109
end
1099-
if span > 0 and ensure_statuscolumn(source_win, span) == nil then
1110+
if span > 0 and ensure_statuscolumn(source_win, span, active_config.float.placement.gutter_position) == nil then
11001111
close_source(source_win)
11011112
return nil
11021113
end
@@ -1124,7 +1135,11 @@ local function render_source(source_win, selection, root_config)
11241135
if state ~= nil then
11251136
close_state(state)
11261137
if reserved then
1127-
ensure_statuscolumn(source_win, math.max(0, output.width + active_config.float.placement.col))
1138+
ensure_statuscolumn(
1139+
source_win,
1140+
math.max(0, output.width + active_config.float.placement.col),
1141+
active_config.float.placement.gutter_position
1142+
)
11281143
end
11291144
end
11301145
local active_float_config =

lua/scrollbar/types.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
---@alias ScrollbarPlacementRelative "window"|"editor"
66
---@alias ScrollbarFloatAnchor "NW"|"NE"|"SW"|"SE"
77
---@alias ScrollbarGutterMode "avoid"|"overlap"
8+
---@alias ScrollbarGutterPosition "inner"|"outer"
89
---@alias ScrollbarLayoutDirection "auto"|"ltr"|"rtl"
910
---@alias ScrollbarSearchBackend "sync"|"worker"
1011
---@alias ScrollbarText string|string[]
@@ -18,6 +19,7 @@
1819
---@field row? integer
1920
---@field col? integer
2021
---@field gutter? ScrollbarGutterMode
22+
---@field gutter_position? ScrollbarGutterPosition
2123

2224
---@class ScrollbarUserFloatConfig
2325
---@field zindex? integer
@@ -143,6 +145,7 @@
143145
---@field row integer
144146
---@field col integer
145147
---@field gutter ScrollbarGutterMode
148+
---@field gutter_position ScrollbarGutterPosition
146149

147150
---@class ScrollbarFloatConfig
148151
---@field zindex integer

tests/test_config.lua

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ T["normalizes a fresh one-column default without setup-only state"] = function()
5555
local second = set()
5656
expect.equality(second.visibility, "all")
5757
expect.equality(second.float.placement.gutter, "avoid")
58+
expect.equality(second.float.placement.gutter_position, "inner")
5859
expect.equality(second.layout.direction, "auto")
5960
expect.equality(second.layout.width, 1)
6061
expect.equality(layer_kinds(second.layout.columns[1]), { "track", "thumb", "marks" })
@@ -182,7 +183,14 @@ T["accepts the complete typed runtime schema"] = function()
182183
float = {
183184
zindex = 60,
184185
hide_on_cursor = false,
185-
placement = { relative = "editor", anchor = "SW", row = -2, col = 3, gutter = "overlap" },
186+
placement = {
187+
relative = "editor",
188+
anchor = "SW",
189+
row = -2,
190+
col = 3,
191+
gutter = "overlap",
192+
gutter_position = "outer",
193+
},
186194
},
187195
layout = {
188196
direction = "rtl",
@@ -222,6 +230,7 @@ T["accepts the complete typed runtime schema"] = function()
222230
expect.equality(result.max_lines, 1000)
223231
expect.equality(result.float.placement.row, -2)
224232
expect.equality(result.float.placement.gutter, "overlap")
233+
expect.equality(result.float.placement.gutter_position, "outer")
225234
expect.equality(result.layout.direction, "rtl")
226235
expect.equality(result.layout.width, 2)
227236
expect.equality(result.track.highlight, track_highlight)
@@ -339,6 +348,11 @@ T["failed setup preserves active config"] = function()
339348
"float.placement.gutter must be one of: avoid, overlap"
340349
)
341350
expect.equality(config.get(), before)
351+
expect_invalid(
352+
{ float = { placement = { gutter_position = "middle" } } },
353+
"float.placement.gutter_position must be one of: inner, outer"
354+
)
355+
expect.equality(config.get(), before)
342356
expect_invalid({ excluded_filetypes = { "lua", false } }, "excluded_filetypes%[2%] must be a string")
343357
expect.equality(config.get(), before)
344358
end

tests/test_presets.lua

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ T["resolves exact isolated built-in presentation definitions"] = function()
149149
thumb = { blend = 60 },
150150
},
151151
emacs = {
152-
float = { placement = { anchor = "NW", gutter = "avoid" } },
152+
float = { placement = { anchor = "NW", gutter = "avoid", gutter_position = "outer" } },
153153
layout = {
154154
direction = "auto",
155155
columns = {
@@ -219,7 +219,7 @@ T["applies built-in overlays inheritance and root overrides deterministically"]
219219
presets = {
220220
child = {
221221
extends = "vscode",
222-
float = { placement = { gutter = "overlap" } },
222+
float = { placement = { gutter = "overlap", gutter_position = "outer" } },
223223
thumb = { blend = 30 },
224224
marks = { Search = { text = { "C" } } },
225225
},
@@ -234,6 +234,7 @@ T["applies built-in overlays inheritance and root overrides deterministically"]
234234

235235
expect.equality(result.layout.columns[1][1], "track")
236236
expect.equality(result.float.placement.gutter, "overlap")
237+
expect.equality(result.float.placement.gutter_position, "outer")
237238
expect.equality(result.thumb, { text = "V", blend = 40 })
238239
expect.equality(result.marks.Search.text, { "R" })
239240
expect.equality(result.preset, nil)

tests/test_profiles.lua

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ T["compiles profile variants with setup-local presets and settled precedence"] =
3535
match = { filetypes = { "lua" } },
3636
preset = "local_review",
3737
config = {
38-
float = { placement = { gutter = "overlap" } },
38+
float = { placement = { gutter = "overlap", gutter_position = "outer" } },
3939
thumb = { blend = 10 },
4040
mouse = { enabled = false },
4141
},
@@ -57,12 +57,14 @@ T["compiles profile variants with setup-local presets and settled precedence"] =
5757
expect.equality(variants[2].config.thumb.text, "R")
5858
expect.equality(variants[2].config.thumb.blend, 10)
5959
expect.equality(variants[2].config.float.placement.gutter, "overlap")
60+
expect.equality(variants[2].config.float.placement.gutter_position, "outer")
6061
expect.equality(variants[2].config.mouse.enabled, false)
6162
expect.equality(variants[2].config.visibility, root.visibility)
6263
expect.equality(variants[3].id, 2)
6364
expect.equality(variants[3].config.layout.width, 1)
6465
expect.equality(variants[3].config.thumb.blend, 35)
6566
expect.equality(variants[3].config.float.placement.gutter, "avoid")
67+
expect.equality(variants[3].config.float.placement.gutter_position, "inner")
6668
expect.equality(variants[3].config.render.geometry, "screen")
6769

6870
for _, variant in ipairs(variants) do
@@ -92,6 +94,14 @@ T["validates profile schema and the nested render-safe boundary"] = function()
9294
},
9395
},
9496
}, "float.placement.gutter must be one of: avoid, overlap")
97+
expect_invalid({
98+
profiles = {
99+
{
100+
match = { filetypes = { "lua" } },
101+
config = { float = { placement = { gutter_position = "middle" } } },
102+
},
103+
},
104+
}, "float.placement.gutter_position must be one of: inner, outer")
95105

96106
local rejected = {
97107
{ show = false },

0 commit comments

Comments
 (0)