diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index ab2171a..003cb47 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -169,6 +169,13 @@ jobs: - name: Run test suite (minimal) run: su -s /bin/bash -c 'HOME=/home/testuser; export HOME; export TERM=xterm-256color; cd ~/dotfiles && bash test.sh minimal' testuser + - name: Readable colours on a terminal without truecolor (#53) + run: | + # The probe needs a PTY, so `script` must exist or the test would skip + # on every cell and prove nothing while still reporting a pass. + command -v script >/dev/null || { echo "script(1) missing - the colour test cannot run"; exit 1; } + su -s /bin/bash -c 'HOME=/home/testuser; export HOME; cd ~/dotfiles && bash tests/nvim-colour-fallback.sh' testuser + git-partial-clone-gate: name: "git without partial clone @ Ubuntu 16.04" runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 9b3bcce..bcab5ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,15 @@ on an nvim 0.11+ host with no build tools. Telescope's own sorter is the fallbac Same rule as the npm guard - do not remove it, and keep optional native extensions behind it. +**`truecolor_ok`** gates `termguicolors` and the colorscheme choice. nightfly, like +most modern schemes, sets only gui colours - measured 2026-09-18, its `Normal` and +`Comment` carry no `ctermfg`/`ctermbg` at all - so forcing `termguicolors` on a +terminal that cannot parse `38;2;R;G;B` left nothing readable and rendered near-black +on near-black (#53). The gate fires only on positive evidence of a low-colour terminal, +so 256-colour hosts are unchanged. **Keep any truecolor-only scheme behind it**, and +keep the fallback list to schemes that really define cterm colours (habamax, desert - +`default` and gruvbox do not). `tests/nvim-colour-fallback.sh` asserts both arms. + **Version gates** - supported hosts run nvim 0.9-0.12. Options and plugins that need a newer nvim are gated (`vim.fn.has('nvim-0.X')`, lazy `cond`), because one invalid option value aborts the rest of init.lua. Parser installs are gated on nvim 0.12, diff --git a/CHANGELOG.md b/CHANGELOG.md index 188c8de..a27d812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.11.8] - 2026-09-18 + +### Fixed +- nvim rendered near-black on near-black on terminals without truecolor (#53). Such terminals now get `habamax` and `notermguicolors`; 256-colour terminals are unchanged. Force the old behaviour in `~/.config/nvim/local.lua`. + ## [1.11.7] - 2026-09-17 ### Added diff --git a/VERSION b/VERSION index 29eb291..40faed9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.7 +1.11.8 diff --git a/nvim/.config/nvim/init.lua b/nvim/.config/nvim/init.lua index f92a42c..a9c67d4 100644 --- a/nvim/.config/nvim/init.lua +++ b/nvim/.config/nvim/init.lua @@ -25,6 +25,19 @@ end -- telescope key was dead on an nvim 0.11+ host with no build tools. Telescope's own -- sorter is the fallback. local fzf_ok = vim.fn.executable('make') == 1 and vim.fn.executable(cc) == 1 +-- nightfly, like most modern schemes, defines ONLY gui colours: measured 2026-09-18, +-- its Normal and Comment carry no ctermfg/ctermbg at all. So forcing termguicolors on +-- a terminal that cannot parse `38;2;R;G;B` leaves nothing readable behind, which is +-- the near-black-on-near-black report (#53). +-- This gate only fires on POSITIVE evidence of a low-colour terminal, so every host +-- reporting 256 colours keeps nightfly and truecolor exactly as before. A capable +-- terminal that advertises neither (ssh does not forward COLORTERM by default) lands +-- on the readable fallback - visible and overridable, which is the safe direction. +local _term = (vim.env.TERM or ''):lower() +local _colorterm = (vim.env.COLORTERM or ''):lower() +local truecolor_ok = _colorterm == 'truecolor' or _colorterm == '24bit' + or _term:find('256', 1, true) ~= nil + or _term:find('direct', 1, true) ~= nil local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim' if not (vim.uv or vim.loop).fs_stat(lazypath) then local clone = { 'git', 'clone', 'https://github.com/folke/lazy.nvim.git', '--branch=stable', lazypath } @@ -66,7 +79,16 @@ require('lazy').setup({ lazy = false, priority = 1000, config = function() - vim.cmd.colorscheme('nightfly') + if truecolor_ok then + vim.cmd.colorscheme('nightfly') + else + -- Low-colour terminal: pick the first scheme that actually sets ctermfg/ctermbg. + -- Measured 2026-09-18 under notermguicolors: habamax Normal ctermfg=251/ctermbg=234, + -- desert 231/236, while nightfly, gruvbox and `default` set none at all. + for _, s in ipairs({ 'habamax', 'desert', 'default' }) do + if pcall(vim.cmd.colorscheme, s) then break end + end + end end, }, -- Installed (available via cs - all load at VeryLazy): @@ -672,7 +694,7 @@ opt.undofile = true opt.undodir = undodir opt.visualbell = true opt.wildmode = 'list:longest' -opt.termguicolors = true +opt.termguicolors = truecolor_ok -- see the truecolor_ok gate at the top of this file opt.matchpairs:append('<:>') -- linematch realigns hunks for cleaner inline diffs. inline:word (changed words diff --git a/nvim/.config/nvim/local.lua.example b/nvim/.config/nvim/local.lua.example index 20a3667..0f331b1 100644 --- a/nvim/.config/nvim/local.lua.example +++ b/nvim/.config/nvim/local.lua.example @@ -5,6 +5,13 @@ -- Override colorscheme -- vim.cmd.colorscheme('catppuccin') +-- Force truecolor on a terminal that supports it but does not say so. init.lua +-- drops to a 256-colour scheme unless TERM carries `256`/`direct` or COLORTERM is +-- set, because a scheme with no cterm colours is unreadable when RGB does not +-- arrive. ssh does not forward COLORTERM, so a capable remote host can land here. +-- vim.o.termguicolors = true +-- vim.cmd.colorscheme('nightfly') + -- Machine-specific LSP extras (e.g. rust-analyzer only on dev machines) -- require('lspconfig').rust_analyzer.setup({}) diff --git a/tests/nvim-colour-fallback.sh b/tests/nvim-colour-fallback.sh new file mode 100755 index 0000000..593faa4 --- /dev/null +++ b/tests/nvim-colour-fallback.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Assert the nvim config stays readable on a terminal that cannot carry truecolor. +# +# Issue #53: nightfly defines only gui colours (measured: Normal and Comment carry +# no ctermfg/ctermbg at all), while init.lua forced `termguicolors` on. A terminal +# that cannot parse `38;2;R;G;B` was then left with nothing to fall back to, which +# renders as near-black text on a near-black background. +# +# Two arms, because a fix that made every host readable by downgrading everyone +# would be a regression for the hosts that were fine: +# low colour (TERM=xterm) -> some scheme with real ctermfg AND ctermbg +# 256 colour (TERM=xterm-256color) -> nightfly and termguicolors, unchanged +set -euo pipefail + +DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INIT="$DOTFILES_DIR/nvim/.config/nvim/init.lua" + +_fail=0 +_ran=0 +_err() { printf ' ERROR: %s\n' "$1"; _fail=1; _ran=$(( _ran + 1 )); } +_ok() { printf ' OK: %s\n' "$1"; _ran=$(( _ran + 1 )); } + +if ! command -v nvim >/dev/null 2>&1; then + echo "RESULT: SKIPPED and not verified - nvim is not installed" + exit 0 +fi + +tmp=$(mktemp -d) +# shellcheck disable=SC2064 +trap "rm -rf '$tmp'" EXIT + +cat > "$tmp/probe.lua" <<'LUA' +local f = io.open(os.getenv('NVCOLOUR_OUT'), 'w') +local h = vim.api.nvim_get_hl(0, { name = 'Normal' }) +f:write(string.format('scheme=%s tgc=%s ctermfg=%s ctermbg=%s\n', + tostring(vim.g.colors_name), tostring(vim.o.termguicolors), + tostring(h.ctermfg), tostring(h.ctermbg))) +f:close() +LUA + +# A PTY is required: without one nvim takes a different startup path entirely. +_probe() { # $1 = TERM value, prints the probe line + local out="$tmp/out.$1" + NVCOLOUR_OUT="$out" TERM="$1" COLORTERM='' timeout 180 script -qec \ + "nvim --clean -u '$INIT' -c 'luafile $tmp/probe.lua' -c 'qa!'" /dev/null \ + >/dev/null 2>&1 || true + [ -f "$out" ] && cat "$out" || echo "scheme=NONE tgc=? ctermfg=? ctermbg=?" +} + +echo "arm 1: low-colour terminal (TERM=xterm)" +low=$(_probe xterm) +echo " $low" +case "$low" in + *"ctermbg=nil"*|*"ctermbg=?"*) + _err "no ctermbg on a low-colour terminal - the buffer has no readable background" ;; + *"ctermfg=nil"*) + _err "no ctermfg on a low-colour terminal - text falls back to the terminal default" ;; + *) _ok "low-colour terminal gets a scheme with real cterm colours" ;; +esac +case "$low" in + *"tgc=true"*) _err "termguicolors still on for a low-colour terminal - RGB it cannot parse" ;; + *) _ok "termguicolors off for a low-colour terminal" ;; +esac + +echo "arm 2: 256-colour terminal (TERM=xterm-256color) - must be unchanged" +hi=$(_probe xterm-256color) +echo " $hi" +_skipped="" +_data=$(nvim --headless -c 'lua io.write(vim.fn.stdpath("data"))' -c 'qa!' 2>/dev/null || true) +if [ -z "$_data" ]; then + # Never let a failed probe read as "nothing to check": that would skip arm 2 + # on every run while the verdict still said PASSED. + _err "could not resolve nvim's data dir - cannot tell whether nightfly is installed" +elif [ ! -d "$_data/lazy/nightfly" ]; then + # Arm 2 asserts the scheme is still nightfly, which needs the plugin on disk. + # Without it this arm would fail for a reason that is not the defect under test. + _skipped="arm 2 (the nightfly plugin is not installed)" + echo " SKIP: $_skipped" +else + case "$hi" in + *"scheme=nightfly"*) _ok "256-colour terminal keeps nightfly" ;; + *) _err "256-colour terminal no longer gets nightfly - this is a regression" ;; + esac + case "$hi" in + *"tgc=true"*) _ok "256-colour terminal keeps termguicolors" ;; + *) _err "256-colour terminal lost termguicolors - this is a regression" ;; + esac +fi + +echo +_verdict=$([ "$_fail" -eq 0 ] && echo PASSED || echo FAILED) +if [ -n "$_skipped" ]; then + printf 'RESULT: %s (%d checks ran), 2 checks SKIPPED and not verified - %s\n' \ + "$_verdict" "$_ran" "$_skipped" +else + printf 'RESULT: %s (%d checks ran, none skipped)\n' "$_verdict" "$_ran" +fi +exit "$_fail"