aboutsummaryrefslogtreecommitdiffstats
path: root/plugin/50-ui.lua
blob: c751c32c4262baa72f0daad24b480add2cb149f6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
--[[ 50-ui.lua — per-colorscheme highlight tweaks, lualine statusline, window bar, and tabline.

Registers a highlight/statusline adjustment bundle per colorscheme into color, which
re-applies the active scheme's bundle on colorscheme / 'background' changes and once after startup.
Switch scheme with `:colors <name>`; both solarized and selenized carry their own adjustments.
Also styles the popup menu and tabpage titles, briefly highlights yanked text, and sets the
default colorscheme (selenized) and background (dark).

User commands:
  `ThemeSwitch`:         toggle light/dark background
  `TabRename`:           rename the current tab
  `TabRenameFileName`:   rename the current tab to the active buffer's file name
  `LualineConfig`:       show the resolved lualine configuration
]]

local color = require("color")
local lualine = require("lualine")
local help = require("help")
local format = require("format")

----------------------------------------------------------------------------------------------------
-- Highlights --------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

--[[ The active colorscheme's key highlight-group definitions, keyed by short field name.
`func`, `str`, `normal`, `comment`, `special`, `statement`, and `error` hold the colorscheme's
`Function`, `String`, `Normal`, `Comment`, `Special`, `Statement`, and `Error` highlight
definitions.
Returning the whole definitions (not just a field) lets callers pull any attribute they need.
Sourcing them from the scheme's own groups makes the styling follow both the colorscheme and its
'background', so it stays in tune with light/dark.
The foregrounds are named after their source group rather than a hue, since that varies by
scheme. ]]
local function current_colors()
	return {
		func = vim.api.nvim_get_hl(0, { name = "Function", link = false }),
		str = vim.api.nvim_get_hl(0, { name = "String", link = false }),
		normal = vim.api.nvim_get_hl(0, { name = "Normal", link = false }),
		comment = vim.api.nvim_get_hl(0, { name = "Comment", link = false }),
		special = vim.api.nvim_get_hl(0, { name = "Special", link = false }),
		winbar = vim.api.nvim_get_hl(0, { name = "WinBar", link = false }),
		winbarnc = vim.api.nvim_get_hl(0, { name = "WinBarNC", link = false }),
		statement = vim.api.nvim_get_hl(0, { name = "Statement", link = false }),
		error = vim.api.nvim_get_hl(0, { name = "Error", link = false }),
	}
end

--[[ Highlight applier: the tweaks common to every colorscheme, from the `colors` table.
It:
  - styles the window bar: swaps `WinBar`/`WinBarNC` backgrounds so the focused window's bar stands
    out, and colors the `WinBarScope`/`WinBarCwd`/`WinBarFilePath` segments from
    `Comment`/`Function`/`Normal` with no background of their own, so they inherit their
    window's bar;
  - blends the floating and popup-menu windows into the editor background (borders included), and
    makes the selected item's matched text (`PmenuMatchSel`) pop in a bold `Special` foreground;
  - links `MatchParen` to the search highlight;
  - colors `gitDecorationHead` (git `HEAD` in the `after/syntax/git.vim` decoration lists);
  - colors the statusline flags on the `StatusLine` background so they blend into their section —
    green bold `StatusFlagOn`, dimmed `StatusFlagOff`, and the `Auto[…]` framing in bold-bracketed
    `StatusFlagAuto` and separator `StatusFlagSep`;
  - gives `Todo` a high-contrast fill;
  - accents `FoldCount` (the `[N]` line count `fold` draws on a closed fold) in bold
    magenta. ]]
local function apply_common_highlights(colors)
	--[[ Swap WinBar and WinBarNC backgrounds so the focused window's bar stands out.
	The scheme gives the active WinBar the plain Normal background and the inactive WinBarNC a
	standout one; swapping them flips that.
	Guarded on WinBar still holding the Normal background: the swap reads the very groups it
	writes, so re-running it unconditionally on later theme events would undo its own work. ]]
	vim.api.nvim_set_hl(0, "WinBarScope", { fg = colors.comment.fg })
	vim.api.nvim_set_hl(0, "WinBarCwd", { fg = colors.func.fg })
	vim.api.nvim_set_hl(0, "WinBarFilePath", { fg = colors.normal.fg })
	vim.api.nvim_set_hl(0, "NormalFloat", { link = "Normal" })
	vim.api.nvim_set_hl(0, "FloatBorder", { bg = colors.normal.bg, update = true })
	vim.api.nvim_set_hl(0, "Pmenu", { bg = colors.normal.bg, update = true })
	vim.api.nvim_set_hl(0, "PmenuSbar", { bg = colors.normal.bg, update = true })
	vim.api.nvim_set_hl(0, "PmenuMatch", { fg = colors.special.fg, bold = true })
	vim.api.nvim_set_hl(0, "MatchParen", { link = "CurSearch" })
	vim.api.nvim_set_hl(0, "gitDecorationHead", { fg = colors.str.fg, bold = true })
	local statusline = vim.api.nvim_get_hl(0, { name = "StatusLine", link = false })
	vim.api.nvim_set_hl(0, "StatusFlagOn", { fg = colors.str.fg, bg = statusline.bg, bold = true })
	vim.api.nvim_set_hl(0, "StatusFlagOff", { fg = colors.comment.fg, bg = statusline.bg })
	vim.api.nvim_set_hl(0, "StatusFlagSep", { fg = colors.normal.fg, bg = statusline.bg })
	vim.api.nvim_set_hl(
		0,
		"StatusFlagAuto",
		{ fg = colors.normal.fg, bg = statusline.bg, bold = true }
	)
	vim.api.nvim_set_hl(0, "Todo", { bg = "Yellow", fg = "Black", bold = true })
	vim.api.nvim_set_hl(0, "FoldCount", { fg = "#d33682", bold = true })
end

--[[ Highlight applier: solarized-specific tweaks, from the `colors` table.
Emphasizes the inactive tabpages (`TabLine`) so the selected one blends into the tabline, and blends
the `Folded` background into `Normal` so folds in unfocused windows stay readable. ]]
local function apply_solarized_highlights(colors)
	vim.api.nvim_set_hl(0, "TabLine", { bold = true, italic = true, update = true })
	vim.api.nvim_set_hl(0, "Folded", { bg = colors.normal.bg, underline = true, update = true })
end

--[[ Highlight applier: selenized-specific tweaks.
Selenized's `Visual` background barely differs from `Normal`, leaving the selection nearly
invisible; link it to `IncSearch`, the scheme's high-contrast reverse-video "match" highlight, so
the selection stands out clearly and follows light/dark on its own.
Selenized also blanks `WarningMsg` and `ErrorMsg`, the groups `vim.notify()` colors WARN and ERROR
with; restore their yellow and red from the `colors` table's `statement` and `error` foregrounds,
copying only the foreground so they track light/dark without inheriting the source group's other
attributes. ]]
local function apply_selenized_highlights(colors)
	vim.api.nvim_set_hl(0, "Visual", { link = "IncSearch" })
	local warn, err = colors.statement, colors.error
	vim.api.nvim_set_hl(0, "WarningMsg", { fg = warn.fg, ctermfg = warn.ctermfg })
	vim.api.nvim_set_hl(0, "ErrorMsg", { fg = err.fg, ctermfg = err.ctermfg })
end

-- Briefly highlight the yanked region.
local function highlight_yank()
	vim.hl.on_yank()
end

--[[ Highlight applier: swap the tabpage title bars' emphasis between the active and inactive tabs.
The active tabpage (`TabLineSel`) takes the editor's `Normal` background so it flows into the window
below, while the inactive ones (`TabLine`) take the emphasized background the active tab used to
have.
The tabpage-id segments are accented in the `colors` table's `func` foreground: `TabLineTabId` for
an inactive tabpage, `TabLineSelTabId` for the active one.
Guarded on TabLineSel still holding a non-`Normal` background: the swap reads the group it
overwrites, so re-running it once TabLineSel already carries the `Normal` background would collapse
`TabLine` onto `Normal` too. ]]
local function apply_tabpage_title_highlights(colors)
	local tablinesel = vim.api.nvim_get_hl(0, { name = "TabLineSel", link = false })
	if tablinesel.bg == colors.normal.bg then
		return
	end
	vim.api.nvim_set_hl(0, "TabLine", { fg = colors.normal.fg, bg = tablinesel.bg, update = true })
	vim.api.nvim_set_hl(0, "TabLineSel", { fg = colors.normal.fg, bg = colors.normal.bg })
	vim.api.nvim_set_hl(0, "TabLineTabId", { fg = colors.func.fg, bg = tablinesel.bg, bold = true })
	vim.api.nvim_set_hl(0, "TabLineSelTabId", { fg = colors.func.fg, bg = colors.normal.bg })
end

----------------------------------------------------------------------------------------------------
-- Statusline --------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

--[[ Enabled spell languages as `Spelling(<langs>)`, or nil when 'spelllang' matches none of these.
Fed to `flag` as the spelling label while spell is on. ]]
local function spell_langs()
	if vim.o.spelllang == "fr" then
		return "Spelling(fr)"
	elseif vim.o.spelllang == "en_us" then
		return "Spelling(en)"
	elseif vim.o.spelllang == "en_us,fr" then
		return "Spelling(en-fr)"
	end
end

--[[ One colored statusline flag: `label` in `StatusFlagOn` (on) or `StatusFlagOff` (off).
The green/dimmed color alone signals the state. ]]
local function flag(label, on)
	return "%#" .. (on and "StatusFlagOn" or "StatusFlagOff") .. "#" .. label
end

--[[ The colored spelling flag, or nil when it should be hidden.
`Spelling(<langs>)` in green while spell is on; `Spelling` in gray only in markdown and text
buffers, hidden elsewhere. ]]
local function spell_flag()
	if vim.o.spell then
		local langs = spell_langs()
		return langs and flag(langs, true)
	elseif vim.bo.filetype == "markdown" or vim.bo.filetype == "text" then
		return flag("Spelling", false)
	end
end

--[[ The grouped auto-feature flag `Auto[Write,Format,Reload]`.
The `Auto[`/`,`/`]` framing lives in `StatusFlagAuto` (bold brackets) and `StatusFlagSep`
(commas), each inner label green when its feature is on (autowrite/format/autoreload) and gray
when off.
When all three are off the framing too dims to the gray `StatusFlagOff`, so the whole flag reads
as inactive. ]]
local function auto_flag()
	local any_on = vim.b.autowrite or vim.b.format or vim.b.autoreload
	local brace = any_on and "StatusFlagAuto" or "StatusFlagOff"
	local sep = any_on and "StatusFlagSep" or "StatusFlagOff"
	return "%#"
		.. brace
		.. "#Auto["
		.. flag("Write", vim.b.autowrite)
		.. "%#"
		.. sep
		.. "#,"
		.. flag("Format", vim.b.format)
		.. "%#"
		.. sep
		.. "#,"
		.. flag("Reload", vim.b.autoreload)
		.. "%#"
		.. brace
		.. "#]"
end

--[[ Custom status flags for the current buffer, space-joined.
Terminal buffers show only `Following output` (:TerminalFollow); other non-editable buffers show
nothing.
Editable buffers (empty 'buftype') show the grouped `Auto[Write,Format,Reload]` flag, the spell
flag, then `LSPHints`, each label green when on and gray when off; the spell flag follows
spell_flag's own visibility rule. ]]
local function statuses()
	if vim.bo.buftype == "terminal" then
		return flag("Following output", vim.b.terminal_follow)
	end
	if vim.bo.buftype ~= "" then
		return ""
	end
	local parts = { auto_flag() }
	local spell = spell_flag()
	if spell then
		parts[#parts + 1] = spell
	end
	parts[#parts + 1] = flag("LSPHints", vim.lsp.inlay_hint.is_enabled({ bufnr = 0 }))
	return table.concat(parts, " ")
end

-- Name of the formatter registered for the current buffer's filetype, empty when none.
local function formatter()
	local cmd = format.formatters[vim.bo.filetype]
	return cmd and "󰛘 " .. cmd[1] or ""
end

--[[ Re-bold lualine's section-b-backed diff/diagnostics highlight groups.
lualine builds its own highlights for the diff/diagnostics components
(`lualine_b_diff_added_normal`, `lualine_b_diagnostics_error_normal`, ...).
Stamp bold onto them with `update = true` so only the bold attribute changes — fg and section b's
background are left untouched.
Must run after lualine (re)creates the groups: initial setup, theme switch, colorscheme. ]]
local function bold_diff_diag()
	for name in pairs(vim.api.nvim_get_hl(0, {})) do
		if name:find("^lualine_b_diff") or name:find("^lualine_b_diagnostics") then
			vim.api.nvim_set_hl(0, name, { bold = true, update = true })
		end
	end
end

-- Diff counts for lualine's diff component, sourced from gitsigns' status dict.
local function gitsigns_diff()
	local gs = vim.b.gitsigns_status_dict
	if gs then
		return { added = gs.added, modified = gs.changed, removed = gs.removed }
	end
end

--[[ Whether git info (repo name, branch) should surface, cached in `vim.b.repo_visible`.
A repo that hides untracked files (`status.showUntrackedFiles=no`, e.g. the bare-`$HOME` dotfiles
repo whose work tree is the whole home directory) shows only when the cwd or the current file is
tracked, so a stray untracked directory under its work tree does not borrow its name and branch.
Any other repo — or none, handled downstream by the resolvers — always shows. ]]
local function repo_visible()
	if vim.b.repo_visible ~= nil then
		return vim.b.repo_visible
	end
	local dir = vim.fn.getcwd()
	local hide = vim.system(
		{ "git", "-C", dir, "config", "status.showUntrackedFiles" },
		{ text = true }
	)
		:wait()
	if vim.trim(hide.stdout) ~= "no" then
		vim.b.repo_visible = true
		return true
	end
	local paths = { "git", "-C", dir, "ls-files", "--", dir }
	local file = vim.fn.expand("%:p")
	if file ~= "" then
		table.insert(paths, file)
	end
	local tracked = vim.system(paths, { text = true }):wait()
	vim.b.repo_visible = vim.trim(tracked.stdout) ~= ""
	return vim.b.repo_visible
end

--[[ Combined repo label for the statusline: the repo name immediately followed by its git branch.
The two surface as one component: the repo name is the `origin` remote basename of the git repo at
the current working directory, `.git` stripped (e.g. `git@host:git/nvim-config.git` →
`nvim-config`); the branch follows, prefixed with the branch glyph (`U+E0A0`) and empty when
detached, bare, or outside a repo.
The whole label is empty when repo_visible hides the repo.
The cwd is the current window's effective one, `getcwd()` resolving the window > tab > global
(`lcd` > `tcd` > `cd`) precedence — so it works for non-file buffers too.
The name and branch cache separately in `vim.b.repo_name` and `vim.b.git_branch` (empty strings
cached too) so redraws reuse them instead of respawning git; refresh_git_repo clears the name on a
cwd change, refresh_git_status the branch on a checkout or staging change, leaving the remote
lookup untouched. ]]
local function repo_label()
	if not repo_visible() then
		return ""
	end
	local dir = vim.fn.getcwd()
	if vim.b.repo_name == nil then
		vim.b.repo_name = ""
		local git = vim.system({ "git", "-C", dir, "remote", "get-url", "origin" }, { text = true })
		local result = git:wait()
		if result.code == 0 then
			vim.b.repo_name = vim.fn.fnamemodify(vim.trim(result.stdout), ":t:r")
		end
	end
	if vim.b.git_branch == nil then
		vim.b.git_branch = ""
		local git = vim.system({ "git", "-C", dir, "branch", "--show-current" }, { text = true })
		local result = git:wait()
		local branch = result.code == 0 and vim.trim(result.stdout) or ""
		if branch ~= "" then
			vim.b.git_branch = "  " .. branch
		end
	end
	return vim.b.repo_name .. vim.b.git_branch
end

--[[ Clear the git-status caches a checkout or staging change can invalidate.
That's the branch and the show/hide visibility; they re-resolve on the next redraw.
Hooked to gitsigns' `GitSignsUpdate`. ]]
local function refresh_git_status()
	vim.b.git_branch = nil
	vim.b.repo_visible = nil
end

--[[ Clear every cached git-statusline value after a cwd change, which may land in a different repo.
It clears the repo name, on top of the checkout/staging caches refresh_git_status already handles.
Hooked to `DirChanged`. ]]
local function refresh_git_repo()
	vim.b.repo_name = nil
	refresh_git_status()
end

--[[ The filetype and formatter components carry an empty `separator` so no delimiter splits the y
section: filetype, formatter, and lsp_status render as one run.
selectioncount and searchcount take a bold yellow foreground to stand out on the z section's
blue. ]]
local lualine_sections = {
	lualine_b = { repo_label, { "diff", source = gitsigns_diff }, "diagnostics" },
	lualine_c = { statuses },
	lualine_x = { "encoding", "fileformat" },
	lualine_y = {
		{ "filetype", separator = "" },
		{ formatter, separator = "" },
		"lsp_status",
	},
	lualine_z = {
		"location",
		"progress",
		{ "selectioncount", color = { fg = "#dbb32d", gui = "bold" } },
		{ "searchcount", color = { fg = "#dbb32d", gui = "bold" } },
	},
}

local lualine_options = {
	disabled_filetypes = { "netrw", "qf" },
	component_separators = { left = "|", right = "|" },
	section_separators = { left = "", right = "" },
}

-- Set lualine's theme to `theme` and (re)apply the configuration.
local function configure_lualine(theme)
	lualine_options.theme = theme
	lualine.setup({ options = lualine_options, sections = lualine_sections })
end

--[[ Statusline applier: set lualine's theme to `theme`.
Then re-bold the diff/diagnostics groups once lualine has rebuilt them. ]]
local function apply_statusline(theme)
	configure_lualine(theme)
	vim.schedule(bold_diff_diag)
end

----------------------------------------------------------------------------------------------------
-- Tabline -----------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

-- Fixed display width, in columns, of every tabpage label in the tabline.
local max_tab_label_width = 30

--[[ Handle of the leftmost tabpage drawn last redraw, anchoring the horizontal scroll; nil while
all labels fit.
Stored as a handle, not an index, so it keeps naming the same tab as others open or close beside
it. ]]
local tabline_anchor = nil

--[[ Build the info segment of a tabpage label, e.g. ` #3[+] ` (no styling).
`id` is the tabpage handle, shown after `#`; `is_modified` appends the `[+]` marker when any window
in the tab has pending changes. ]]
local function make_tab_info_label(id, is_modified)
	local marker = is_modified and "[+] " or " "
	return " #" .. id .. marker
end

--[[ Build the title segment of a tabpage label, padded or truncated to `width` (no styling).
Uses the tab's `tabname` if set, else the active buffer's file name, else `[No Name]`. ]]
local function make_tab_label(id, width)
	local tab_label = vim.t[id].tabname
	if tab_label == nil or tab_label == "" then
		local win = vim.api.nvim_tabpage_get_win(id)
		local bufname = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win))
		tab_label = vim.fs.basename(bufname)
	end
	if tab_label == "" then
		tab_label = "[No Name]"
	end
	local display_width = vim.fn.strdisplaywidth(tab_label)
	return (display_width > width) and (vim.fn.strcharpart(tab_label, 0, width - 1) .. "…")
		or (tab_label .. string.rep(" ", width - display_width))
end

-- Whether any window in tabpage `id` holds a buffer with unsaved changes.
local function tab_is_modified(id)
	for _, win in ipairs(vim.api.nvim_tabpage_list_wins(id)) do
		if vim.bo[vim.api.nvim_win_get_buf(win)].modified then
			return true
		end
	end
	return false
end

--[[ Build one styled, `width`-column tabpage label for the tabline.
A `%nT` click region, then the id/info and title segments in the current-tab or inactive highlight
groups, the title padded to fill the width the info segment leaves. ]]
local function make_tab_entry(id, width)
	local is_current = id == vim.api.nvim_get_current_tabpage()
	local tab_info_label = make_tab_info_label(id, tab_is_modified(id))
	local tab_label = make_tab_label(id, width - vim.fn.strdisplaywidth(tab_info_label))
	return "%"
		.. vim.api.nvim_tabpage_get_number(id)
		.. "T"
		.. (is_current and "%#TabLineSelTabId#" or "%#TabLineTabId#")
		.. tab_info_label
		.. (is_current and "%#TabLineSel#" or "%#TabLine#")
		.. tab_label
end

-- Index of tabpage `id` in the `tabs` list, or nil when it is no longer present.
local function tab_index(tabs, id)
	for i, tab in ipairs(tabs) do
		if tab == id then
			return i
		end
	end
end

--[[ First and last index into `tabs` to draw, keeping the `active` tab visible within 'columns'.
`active` is 1-based; scrolling stays as small as possible from the previous redraw.
Returns 1..count while every label fits.
Otherwise the window keeps its stored left edge as long as the active tab stays inside it; the
active tab is drawn last when it has moved past the window's right, first when it has moved past
its left.
One column is reserved on each side for the ‹ / › arrows, so the label count stays fixed however
the arrows toggle.
Updates tabline_anchor to the new left edge. ]]
local function tab_scroll_range(tabs, active)
	local count, columns = #tabs, vim.o.columns
	if count * max_tab_label_width <= columns then
		tabline_anchor = nil
		return 1, count
	end
	local fit = math.max(1, math.floor((columns - 2) / max_tab_label_width))
	local prev = math.min(tab_index(tabs, tabline_anchor) or 1, count - fit + 1)
	local first
	if active < prev then
		first = active
	elseif active > prev + fit - 1 then
		first = active - fit + 1
	else
		first = prev
	end
	first = math.max(1, math.min(first, count - fit + 1))
	tabline_anchor = tabs[first]
	return first, first + fit - 1
end

--[[ Return a string compatible with the 'tabline' option, setting the tabline content and style.
Labels are a fixed max_tab_label_width columns wide; tab_scroll_range keeps the current tab visible
by scrolling the window when they outgrow 'columns', drawing only the labels that fit.
A ‹ or › in the TabLineFill highlight marks a side that has tabs scrolled out of view.
The labels are left-aligned, trailing fill on the right; but when the final tab is on screen under
overflow they are right-aligned (`%=`) so it sits flush against the right edge.
Declared as a global function to be available as `v:lua.GetTabLine` in the Vimscript engine for the
'tabline' option.
See <nvim-help://%27statusline%27>. ]]
function GetTabLine()
	local tabs = vim.api.nvim_list_tabpages()
	local active = tab_index(tabs, vim.api.nvim_get_current_tabpage()) or 1
	local first, last = tab_scroll_range(tabs, active)
	local block = (first > 1) and "%#TabLineFill#‹" or ""
	for i = first, last do
		block = block .. make_tab_entry(tabs[i], max_tab_label_width)
	end
	if last < #tabs then
		return block .. "%#TabLineFill#›%#TabLineFill#"
	end
	-- Final tab visible: right-align under overflow (`first > 1`), else left-align with
	-- trailing fill.
	return (first > 1) and ("%#TabLineFill#%=" .. block) or (block .. "%#TabLineFill#")
end

-- Name the current tab from the command argument.
local function rename_tab(opts)
	vim.t.tabname = opts.args
	vim.cmd.redrawtabline()
end

-- Name the current tab after the active buffer's file name (without extension).
local function rename_tab_to_cur_filename()
	vim.t.tabname = vim.fn.expand("%:t:r")
	vim.cmd.redrawtabline()
end

----------------------------------------------------------------------------------------------------
-- Commands & init ---------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------

--[[ Apply an adjustment bundle to the active colorscheme.
Runs the common highlights, the scheme's own `scheme_highlights(colors)`, lualine set to the
`theme` lualine theme, and accented tabpage titles. ]]
local function apply_bundle(scheme_highlights, theme)
	local colors = current_colors()
	apply_common_highlights(colors)
	scheme_highlights(colors)
	apply_statusline(theme)
	apply_tabpage_title_highlights(colors)
end

-- Adjustment bundle for solarized: solarized highlights and the light/dark solarized lualine theme.
local function apply_solarized()
	local theme = vim.o.background == "light" and "solarized_light" or "solarized_dark"
	apply_bundle(apply_solarized_highlights, theme)
end

-- Adjustment bundle for selenized: selenized highlights and the background-aware selenized theme.
local function apply_selenized()
	apply_bundle(apply_selenized_highlights, "selenized")
end

--[[ Flip between the light and dark backgrounds, then reload the colorscheme.
Reloading makes its base groups re-theme, since some schemes do not self-hook 'background'; the
ColorScheme event re-runs the bundle. ]]
local function switch_theme()
	vim.opt.background = vim.o.background == "light" and "dark" or "light"
	vim.cmd.colorscheme(vim.g.colors_name)
	vim.notify("Color theme set to " .. vim.o.background, vim.log.levels.INFO)
end

-- Show the resolved lualine configuration.
local function show_lualine_config()
	vim.notify(vim.inspect(lualine.get_config()), vim.log.levels.INFO)
end

color.register("solarized", apply_solarized)
color.register("selenized", apply_selenized)

vim.api.nvim_create_autocmd("TextYankPost", {
	desc = "Highlight on yank",
	group = vim.g.dotfiles_augroup,
	callback = highlight_yank,
})

vim.api.nvim_create_autocmd("DirChanged", {
	pattern = "*",
	desc = "Invalidate the cached git statusline info on a cwd change",
	group = vim.g.dotfiles_augroup,
	callback = refresh_git_repo,
})

vim.api.nvim_create_autocmd("User", {
	pattern = "GitSignsUpdate",
	desc = "Invalidate the cached git branch and visibility on a checkout or staging change",
	group = vim.g.dotfiles_augroup,
	callback = refresh_git_status,
})

-- stylua: ignore start
vim.api.nvim_create_user_command("ThemeSwitch", switch_theme, { desc = "Toggle light/dark theme" })
vim.api.nvim_create_user_command("TabRename", rename_tab, { nargs = 1 })
vim.api.nvim_create_user_command("TabRenameFileName", rename_tab_to_cur_filename, { nargs = 0 })
vim.api.nvim_create_user_command("LualineConfig", show_lualine_config, { desc = "Show lualine configuration" })
-- stylua: ignore end

help.register("General", {
	commands = {
		{ "ThemeSwitch", "Toggle light/dark theme" },
		{ "TabRename", "Rename current tab" },
		{ "TabRenameFileName", "Rename tab to current file name" },
		{ "LualineConfig", "Show lualine configuration" },
	},
})

--[[ Window bar: "#<winid> <cwd> | <file>", the cwd prefixed with ⟨win⟩/⟨tab⟩ when it is window- or
tab-local.
Nothing prefixes the common global cwd.
Inside %{} win_getid() is the window the winbar is drawn for (the current window is temporarily
switched to it), so both the id and the cwd are right for inactive windows too — unlike argless
getcwd(), which ignores that switch and always reports the active window's cwd.
Passing the window-ID to getcwd()/haslocaldir() resolves the window > tab > global precedence for
that window.
Styled via the WinBarScope/WinBarCwd/WinBarFilePath groups in apply_common_highlights. ]]
vim.opt.winbar = "%#WinBarScope#%{'#'.win_getid().' '}"
	.. "%{haslocaldir(win_getid()) ? '⟨win⟩ ' : haslocaldir(-1, win_id2tabwin(win_getid())[0]) ? '⟨tab⟩ ' : ''}"
	.. "%#WinBarCwd#%{fnamemodify(getcwd(win_getid()),':~')}%* | %#WinBarFilePath#%f%*"

-- See <nvim-help://stl-%25%21>.
vim.o.tabline = "%!v:lua.GetTabLine()"
vim.cmd.colorscheme("selenized")
vim.opt.background = "dark"