M_B1.1: theme + modifier polish for LSP styling

The grid TUI's M_B1 LspStyleView shipped functional but the default
theme only covered tree-sitter capture names. clangd / rust-analyzer
/ gopls emit LSP-spec SemanticTokenTypes that didn't intersect — so
`LLAMA_LOG_ERROR`, namespace names, parameters, fields, etc. fell
through to default and rendered uncolored. This brings the default
theme up to the LSP vocabulary so what the LSP layer ships actually
paints.

Theme additions (Theme::default_dark): macro, namespace, parameter,
property, class, struct, enum, interface, enumMember, modifier,
decorator, regexp, typeParameter. Refactored default_dark to a
data-driven (name, style) table — cuts the function from ~170 lines
to ~70, and adding a new theme entry now means appending one row
rather than 6 lines of `by_capture.insert(...)`.

LspStyleView::render now builds the lookup name as `<type>.<first-
modifier>` when modifiers are set, else just `<type>`. Theme::lookup's
dotted-prefix walk falls back to base if a refined entry isn't
defined, so the change is a strict refinement: themes that want to
target e.g. `function.defaultLibrary` (clangd's standard-library
modifier) can, themes that don't see no behavior change. Allocation
is skipped in the no-modifier case via `Cow::Borrowed`.

Tests: default_dark_covers_lsp_token_types regression-guards the
LSP-vocabulary coverage; lsp_style_view_uses_modifier_in_capture_lookup
proves a modifier-refined theme entry wins over the base.

The bigger polish (real C++ keyword/string coloring) needs
tree-sitter-cpp + dropping policy A's exclusivity so grammar-backed
languages get both views — separate thread, intentionally out of
scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-19 20:17:14 -04:00
parent d867600aa3
commit 1652837486
1 changed files with 240 additions and 102 deletions

View File

@ -76,111 +76,78 @@ impl Theme {
}
/// A small built-in dark theme that picks up the most common
/// tree-sitter captures so opening a code file produces visible
/// highlighting without a user theme. Uses 8/16-color indexed
/// terminal colors for portability --- truecolor themes can be
/// pushed in from Lua.
/// tree-sitter captures **and** the LSP semantic-token type names
/// clangd / rust-analyzer / gopls actually emit, so opening a code
/// file produces visible highlighting without a user theme. Uses
/// 8/16-color indexed terminal colors for portability — truecolor
/// themes can be pushed in from Lua.
///
/// Capture coverage is intentionally limited: keyword,
/// function, type, string, comment, constant, number, operator,
/// variable. Themes that want more granularity layer on top via
/// [`Self::insert`] / Lua's `pmacs.theme.set`.
/// Themes that want more granularity layer on top via
/// [`Self::insert`] / Lua's `pmacs.theme.set`. The dotted-prefix
/// [`Self::lookup`] means a modifier-refined name like
/// `function.defaultLibrary` falls back to `function` if not
/// defined — safe to query unconditionally.
#[must_use]
pub fn default_dark() -> Self {
let mut by_capture = HashMap::new();
let bold = Style {
// Indexed terminal palette: 1 red, 2 green, 3 yellow, 4 blue,
// 5 magenta, 6 cyan, 8 bright black/gray; bright variants 9..14.
let fg = |c: u8| Style {
fg: Color::Indexed(c),
..Style::default()
};
let fg_bold = |c: u8| Style {
fg: Color::Indexed(c),
bold: true,
..Style::default()
};
// 8-color indexed: 1 red, 2 green, 3 yellow, 4 blue,
// 5 magenta, 6 cyan; bright variants 9..14.
by_capture.insert(
"keyword".to_owned(),
Style {
fg: Color::Indexed(5),
..bold
},
);
by_capture.insert(
"keyword.control".to_owned(),
Style {
fg: Color::Indexed(13),
..bold
},
);
by_capture.insert(
"function".to_owned(),
Style {
fg: Color::Indexed(4),
..Style::default()
},
);
by_capture.insert(
"function.method".to_owned(),
Style {
fg: Color::Indexed(12),
..Style::default()
},
);
by_capture.insert(
"type".to_owned(),
Style {
fg: Color::Indexed(3),
..Style::default()
},
);
by_capture.insert(
"type.builtin".to_owned(),
Style {
fg: Color::Indexed(11),
..Style::default()
},
);
by_capture.insert(
"string".to_owned(),
Style {
fg: Color::Indexed(2),
..Style::default()
},
);
by_capture.insert(
"comment".to_owned(),
Style {
fg: Color::Indexed(8),
italic: true,
..Style::default()
},
);
by_capture.insert(
"constant".to_owned(),
Style {
fg: Color::Indexed(1),
..Style::default()
},
);
by_capture.insert(
"constant.builtin".to_owned(),
Style {
fg: Color::Indexed(9),
..Style::default()
},
);
by_capture.insert(
"number".to_owned(),
Style {
fg: Color::Indexed(1),
..Style::default()
},
);
by_capture.insert(
"operator".to_owned(),
Style {
fg: Color::Indexed(6),
..Style::default()
},
);
by_capture.insert("variable".to_owned(), Style::default());
by_capture.insert("punctuation".to_owned(), Style::default());
let fg_italic = |c: u8| Style {
fg: Color::Indexed(c),
italic: true,
..Style::default()
};
let italic_only = Style {
italic: true,
..Style::default()
};
// (capture name, style). LSP semantic-token type names are the
// unprefixed entries (`macro`, `namespace`, `parameter`, …);
// tree-sitter captures are the dotted ones (`keyword.control`,
// `function.method`, `type.builtin`, `constant.builtin`).
let entries: &[(&str, Style)] = &[
("keyword", fg_bold(5)),
("keyword.control", fg_bold(13)),
("function", fg(4)),
("function.method", fg(12)),
("type", fg(3)),
("type.builtin", fg(11)),
("string", fg(2)),
("comment", fg_italic(8)),
("constant", fg(1)),
("constant.builtin", fg(9)),
("number", fg(1)),
("operator", fg(6)),
("variable", Style::default()),
("punctuation", Style::default()),
// LSP additions (no tree-sitter overlap).
("macro", fg_bold(13)),
("namespace", fg(11)),
("parameter", italic_only),
("property", fg(6)),
("class", fg(3)),
("struct", fg(3)),
("enum", fg(3)),
("interface", fg(3)),
("enumMember", fg(1)),
("modifier", fg_bold(5)),
("decorator", fg(13)),
("regexp", fg(2)),
("typeParameter", fg_italic(11)),
];
let by_capture = entries
.iter()
.map(|(name, style)| ((*name).to_owned(), *style))
.collect();
Self {
by_capture,
default_style: Style::default(),
@ -606,10 +573,26 @@ impl View for LspStyleView {
// semantic-token set; if it ever becomes the bottleneck,
// pre-bucket tokens by line at the top of `render`.
for t in tokens.iter().filter(|t| t.line == line_idx) {
let Some(name) = ctx.legend.as_ref().and_then(|l| l.type_name(t.token_type)) else {
continue; // No legend / unknown index.
let Some(legend) = ctx.legend.as_ref() else {
continue; // No legend ⇒ cannot name a style.
};
let style = theme.lookup(name);
let Some(name) = legend.type_name(t.token_type) else {
continue; // Unknown type index.
};
// Build the lookup name as `<type>.<first-modifier>`
// when modifiers are set, else just `<type>`. The
// theme's dotted-prefix `lookup` walks back to the
// base if a more specific entry isn't defined, so
// adding a modifier suffix is a strict refinement —
// never worse than the unmodified lookup. Allocation
// is skipped in the no-modifier case (the common one)
// via `Cow::Borrowed`.
let mods = legend.modifier_names(t.token_modifiers);
let lookup_name: std::borrow::Cow<'_, str> = match mods.first() {
Some(m) => std::borrow::Cow::Owned(format!("{name}.{m}")),
None => std::borrow::Cow::Borrowed(name),
};
let style = theme.lookup(&lookup_name);
if is_default_style(style) {
continue;
}
@ -729,6 +712,36 @@ mod tests {
assert_eq!(byte_range_to_display_cols(line, 0, 999), (0, 2));
}
#[test]
fn default_dark_covers_lsp_token_types() {
// The LSP semantic-token type names clangd / rust-analyzer /
// gopls actually emit must resolve to a non-default style in
// the built-in theme — otherwise `LspStyleView` drops them as
// "default-styled, nothing to render". Regression guard so a
// future theme refactor doesn't silently lose coverage.
let t = Theme::default_dark();
for name in [
"macro",
"namespace",
"parameter",
"property",
"class",
"struct",
"enum",
"interface",
"enumMember",
"modifier",
"decorator",
"regexp",
"typeParameter",
] {
assert!(
!is_default_style(t.lookup(name)),
"default_dark must color LSP token type `{name}`"
);
}
}
#[test]
fn is_default_style_round_trips() {
assert!(is_default_style(Style::default()));
@ -847,4 +860,129 @@ mod tests {
"col 3 (space) is outside the token range"
);
}
#[test]
#[allow(clippy::too_many_lines)] // Scripted fixture; the headline
// test next door is similarly
// shaped. If a third lands, extract
// a shared helper.
fn lsp_style_view_uses_modifier_in_capture_lookup() {
use crate::cell::{Cell, CellSize};
use crate::editor::EditorState;
use crate::lsp::PositionEncoding;
use crate::semantic_tokens::{SemanticToken, SemanticTokenKey, SemanticTokensResponse};
let state = EditorState::new();
let buffer_id = state.core.borrow().active_window().buffer_id;
{
let mut core = state.core.borrow_mut();
core.registry
.clone()
.borrow_mut()
.get_mut(buffer_id)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"foo bar\n",
})
.expect("seed");
core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/x.cpp")));
}
// Theme: base `function` is bold; the modifier-refined
// `function.defaultLibrary` is italic (overrides bold).
{
let theme_handle = state.syntax_registry.theme();
let mut theme = theme_handle.lock().expect("theme");
theme.insert(
"function",
Style {
bold: true,
..Style::default()
},
);
theme.insert(
"function.defaultLibrary",
Style {
italic: true,
..Style::default()
},
);
}
// Legend: tokenTypes=["function"], tokenModifiers=["defaultLibrary"].
// Token 0: type=0, modifiers=0 (no bit set) ⇒ falls back to `function` (bold).
// Token 1: type=0, modifiers=1 (bit 0 ⇒ defaultLibrary) ⇒ matches
// `function.defaultLibrary` (italic, NOT bold).
let sid = state
.lsp_manager
.borrow_mut()
.insert_initialized_test_client(
serde_json::json!({
"semanticTokensProvider": {
"legend": {
"tokenTypes": ["function"],
"tokenModifiers": ["defaultLibrary"]
}
}
}),
PositionEncoding::Utf16,
);
let active_path = state.core.borrow().active_buffer_path().expect("path set");
let uri = crate::lsp::path_to_file_uri(&active_path);
{
let mgr = state.lsp_manager.borrow();
let store = mgr.semantic_token_store();
store.lock().expect("store").set(
SemanticTokenKey::new(sid.raw().to_string(), uri),
SemanticTokensResponse {
tokens: vec![
SemanticToken {
line: 0,
start: 0,
length: 3,
token_type: 0,
token_modifiers: 0,
},
SemanticToken {
line: 0,
start: 4,
length: 3,
token_type: 0,
token_modifiers: 1,
},
],
result_id: None,
raw: Vec::new(),
},
);
}
let mut view = LspStyleView::new(state.lsp_manager.clone(), state.syntax_registry.theme());
let mut backing: Vec<Cell> = vec![Cell::default(); 20];
let mut grid = CellGrid {
cells: &mut backing,
stride: 20,
size: CellSize::new(1, 20),
};
let viewport = Viewport {
buffer_start: 0,
buffer_end: u64::MAX,
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(1, 20),
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
let buf = reg.get(buffer_id).expect("buffer");
view.render(buf, viewport, &mut grid);
// "foo" (cols 0..3) ⇒ base `function` ⇒ bold.
let c = grid.get(CellCoord::new(0, 0));
assert!(c.style.bold, "unmodified token uses base `function`");
assert!(!c.style.italic);
// "bar" (cols 4..7) ⇒ `function.defaultLibrary` ⇒ italic, no bold.
let c = grid.get(CellCoord::new(0, 4));
assert!(
c.style.italic,
"modifier-refined token uses `function.defaultLibrary`"
);
assert!(!c.style.bold);
}
}