From 8c63141f2eb965a47f932c8fea31cadb3b68ddd3 Mon Sep 17 00:00:00 2001 From: Jason Stiebs Date: Wed, 14 Oct 2026 18:06:10 -0500 Subject: [PATCH] Support styled code lines and custom viewer colors --- apps/gpui/lib/gpui/schema/component/docs.ex | 2 + .../gpui/components/schema_declarations.ex | 1 + apps/gpui_components/lib/gpui/ui.ex | 10 +- .../lib/gpui/ui/collection/validation.ex | 38 ++++++ .../native/src/generated/schema.rs | 2 + .../test/test/gpui/ui/data_views_test.exs | 62 +++++++++ .../src/element/component/code_viewer.rs | 122 +++++++++++++++++- .../native/src/generated/schema.rs | 2 + .../gpui/codegen/native/component/macros.ex | 6 + 9 files changed, 241 insertions(+), 4 deletions(-) diff --git a/apps/gpui/lib/gpui/schema/component/docs.ex b/apps/gpui/lib/gpui/schema/component/docs.ex index b84c2e2f..810de437 100644 --- a/apps/gpui/lib/gpui/schema/component/docs.ex +++ b/apps/gpui/lib/gpui/schema/component/docs.ex @@ -110,6 +110,7 @@ defmodule GPUI.Schema.Component.Docs do defp type_doc(:select_options), do: "list of `t:GPUI.UI.select_option/0`" defp type_doc(:radio_options), do: "list of `t:GPUI.UI.radio_option/0`" defp type_doc(:rich_text_runs), do: "list of `t:GPUI.Text.RichRun.t/0`" + defp type_doc(:text_style_runs), do: "at most 512 `t:GPUI.Text.StyleRun.t/0` values" defp type_doc(:paint_commands), do: "at most 256 bounded rectangle or line commands" defp type_doc(:resource), do: "resource map" @@ -152,6 +153,7 @@ defmodule GPUI.Schema.Component.Docs do defp type_ast(:select_options), do: quote(do: [GPUI.UI.select_option()]) defp type_ast(:radio_options), do: quote(do: [GPUI.UI.radio_option()]) defp type_ast(:rich_text_runs), do: quote(do: [GPUI.Text.RichRun.t()]) + defp type_ast(:text_style_runs), do: quote(do: [GPUI.Text.StyleRun.t()]) defp type_ast(:paint_commands), do: quote(do: [map()]) defp type_ast(:resource), do: quote(do: map()) defp type_ast({:enum, _values}), do: quote(do: String.t()) diff --git a/apps/gpui_components/lib/gpui/components/schema_declarations.ex b/apps/gpui_components/lib/gpui/components/schema_declarations.ex index 69db4a4c..04a015fc 100644 --- a/apps/gpui_components/lib/gpui/components/schema_declarations.ex +++ b/apps/gpui_components/lib/gpui/components/schema_declarations.ex @@ -749,6 +749,7 @@ defmodule GPUI.Components.Schema.Declarations do id: :string, text: {:default, :string}, number: :non_negative_integer, + style_runs: :text_style_runs, kind: {:default, {:enum, diff --git a/apps/gpui_components/lib/gpui/ui.ex b/apps/gpui_components/lib/gpui/ui.ex index 606f2db9..9055c4de 100644 --- a/apps/gpui_components/lib/gpui/ui.ex +++ b/apps/gpui_components/lib/gpui/ui.ex @@ -708,11 +708,18 @@ defmodule GPUI.UI do @doc """ Builds one native line for `code_viewer/1`. + Optional `style_runs` are sorted, non-overlapping `GPUI.Text.StyleRun` values + within this line's original text, using line `0` and UTF-16 offsets. They + describe colors and font styles, not syntax or a particular highlighter. + Tab expansion is native; callers must not adjust ranges for expanded tabs. + Selected lines retain the viewer's high-contrast selection foreground. + #{ComponentsSchema.component_options_doc(:ui_code_line)} """ @spec code_line(code_line_options()) :: Element.t() def code_line(assigns) when is_map(assigns) do - assigns = ComponentsSchema.apply_defaults(assigns, :ui_code_line) + assigns = + assigns |> Map.put_new(:style_runs, []) |> ComponentsSchema.apply_defaults(:ui_code_line) unless is_binary(Map.get(assigns, :id)) and assigns.id != "" and is_binary(assigns.text) do @@ -733,6 +740,7 @@ defmodule GPUI.UI do "ui_code_line kind must be context, addition, deletion, hunk, debug, info, warning, or error" end + Validation.validate_code_line_runs!(assigns) component(:ui_code_line, assigns) end diff --git a/apps/gpui_components/lib/gpui/ui/collection/validation.ex b/apps/gpui_components/lib/gpui/ui/collection/validation.ex index 5e42374d..03e4e130 100644 --- a/apps/gpui_components/lib/gpui/ui/collection/validation.ex +++ b/apps/gpui_components/lib/gpui/ui/collection/validation.ex @@ -177,6 +177,44 @@ defmodule GPUI.UI.Collection.Validation do @max_rich_text_bytes 1_048_576 @max_rich_text_runs 2_048 + def validate_code_line_runs!(assigns) do + GPUI.Components.Schema.validate_component_assigns!(assigns, :ui_code_line) + + if assigns.style_runs != [] and String.contains?(assigns.text, ["\n", "\r"]) do + raise ArgumentError, "ui_code_line style runs require single-line text" + end + + boundaries = + assigns.text + |> String.to_charlist() + |> Enum.scan(0, fn character, offset -> offset + if(character > 0xFFFF, do: 2, else: 1) end) + |> MapSet.new() + |> MapSet.put(0) + + Enum.reduce(assigns.style_runs, 0, fn run, previous_end -> + start = code_position_offset!(run.range.start, boundaries) + ending = code_position_offset!(run.range.end, boundaries) + + unless start < ending and start >= previous_end do + raise ArgumentError, + "ui_code_line style runs must be non-empty, sorted and non-overlapping" + end + + ending + end) + + :ok + end + + defp code_position_offset!(%GPUI.Text.Position{line: 0, utf16_offset: offset}, boundaries) do + if MapSet.member?(boundaries, offset), + do: offset, + else: raise(ArgumentError, "ui_code_line style runs must use UTF-16 character boundaries") + end + + defp code_position_offset!(_position, _boundaries), + do: raise(ArgumentError, "ui_code_line style runs must use positions on line 0") + def validate_rich_text!(assigns) do validate_collection_label!(:ui_rich_text, Map.get(assigns, :label)) text = assigns.text diff --git a/apps/gpui_components/native/src/generated/schema.rs b/apps/gpui_components/native/src/generated/schema.rs index bea7448a..ec1d2e56 100644 --- a/apps/gpui_components/native/src/generated/schema.rs +++ b/apps/gpui_components/native/src/generated/schema.rs @@ -3506,6 +3506,7 @@ pub(crate) struct CodeLineComponentNode { pub(crate) id: String, pub(crate) text: String, pub(crate) number: Option, + pub(crate) style_runs: Vec, pub(crate) kind: Option, pub(crate) disabled: bool, } @@ -3520,6 +3521,7 @@ pub(crate) fn decode_generated_code_line_component<'a>( id: component_id(term)?, text: component_string_attr(term, atoms::text())?.unwrap_or_default(), number: component_non_negative_integer_attr(term, atoms::number())?, + style_runs: text_style_runs_attr(term, atoms::style_runs())?, kind: match component_enum_attr( term, atoms::kind(), diff --git a/apps/gpui_components/test/test/gpui/ui/data_views_test.exs b/apps/gpui_components/test/test/gpui/ui/data_views_test.exs index a0e6a1fe..ae2c8432 100644 --- a/apps/gpui_components/test/test/gpui/ui/data_views_test.exs +++ b/apps/gpui_components/test/test/gpui/ui/data_views_test.exs @@ -2,6 +2,7 @@ defmodule GPUI.UI.DataViewsTest do use ExUnit.Case, async: true alias GPUI.Element + alias GPUI.Text.{Position, Range, StyleRun} alias GPUI.UI test "builds themed shell components with bounded contracts" do @@ -351,6 +352,67 @@ defmodule GPUI.UI.DataViewsTest do assert log_line_attrs[:kind] == "warning" end + test "code lines serialize styles in original UTF-16 coordinates" do + run = StyleRun.new(Range.new(Position.new(0, 3), Position.new(0, 6)), color: 0x8250DF) + line = UI.code_line(%{id: "unicode", text: "😀\tdef", style_runs: [run]}) + assert line.attrs[:style_runs] == [run] + + assert [ + %{ + color: 0x8250DF, + range: %{ + start: %{line: 0, utf16_offset: 3}, + end: %{line: 0, utf16_offset: 6} + } + } + ] = Element.to_payload(line).attrs.style_runs + end + + test "code lines reject invalid style boundaries and overlapping ranges" do + for {start, ending} <- [{1, 2}, {0, 1}, {0, 7}, {3, 3}, {4, 3}] do + run = + StyleRun.new(Range.new(Position.new(0, start), Position.new(0, ending)), color: 0x8250DF) + + assert_raise ArgumentError, fn -> + UI.code_line(%{id: "invalid", text: "😀\tdef", style_runs: [run]}) + end + end + + run = StyleRun.new(Range.new(Position.new(0, 3), Position.new(0, 6)), color: 0x8250DF) + + assert_raise ArgumentError, ~r/non-overlapping/, fn -> + UI.code_line(%{id: "overlap", text: "😀\tdef", style_runs: [run, run]}) + end + + run = StyleRun.new(Range.new(Position.new(1, 0), Position.new(1, 1)), color: 0x8250DF) + + assert_raise ArgumentError, ~r/line 0/, fn -> + UI.code_line(%{id: "line", text: "def", style_runs: [run]}) + end + end + + test "code line styles are bounded and require single-line text" do + run = StyleRun.new(Range.new(Position.new(0, 0), Position.new(0, 1)), color: 0x8250DF) + + for text <- ["a\nb", "a\rb"] do + assert_raise ArgumentError, ~r/single-line/, fn -> + UI.code_line(%{id: "multiline", text: text, style_runs: [run]}) + end + end + + runs = + for offset <- 0..512 do + StyleRun.new( + Range.new(Position.new(0, offset), Position.new(0, offset + 1)), + color: 0x8250DF + ) + end + + assert_raise ArgumentError, fn -> + UI.code_line(%{id: "too-many", text: String.duplicate("a", 513), style_runs: runs}) + end + end + test "validates code viewer structure and line metadata" do line = UI.code_line(%{id: "line-1", number: 1, text: "hello"}) diff --git a/apps/gpui_native/native/src/element/component/code_viewer.rs b/apps/gpui_native/native/src/element/component/code_viewer.rs index f946a442..7a416c4e 100644 --- a/apps/gpui_native/native/src/element/component/code_viewer.rs +++ b/apps/gpui_native/native/src/element/component/code_viewer.rs @@ -32,6 +32,7 @@ impl ComponentCodeViewer { struct CodeLine { id: String, text: String, + style_runs: Vec, number: Option, kind: String, disabled: bool, @@ -96,6 +97,7 @@ pub(crate) fn render( ElementNode::CodeLineComponent(line) => Some(CodeLine { id: line.id, text: line.text, + style_runs: line.style_runs, number: line.number, kind: line.kind.unwrap_or_else(|| "context".to_string()), disabled: line.disabled, @@ -198,8 +200,16 @@ pub(crate) fn render( item_height: (node.item_height as f32).max(1.0), gutter_width, dark: theme.is_dark(), - background: theme.background, - foreground: theme.foreground, + background: node + .style + .background + .map(|color| gpui::rgba(color).into()) + .unwrap_or(theme.background), + foreground: node + .style + .color + .map(|color| gpui::rgba(color).into()) + .unwrap_or(theme.foreground), muted_foreground: theme.muted_foreground, selected_background: theme.primary, selected_foreground: theme.primary_foreground, @@ -372,6 +382,11 @@ fn render_loaded_line( let text_color = line_text_color(code, &line.kind, selected); let gutter_width = code.gutter_width; let expanded = expand_tabs(&line.text, code.tab_width); + let highlights = if selected { + Vec::new() + } else { + line_highlights(&line.text, code.tab_width, &line.style_runs) + }; let accessibility_label = match line.number { Some(number) => format!("Line {number}: {expanded}"), None => expanded.clone(), @@ -397,7 +412,7 @@ fn render_loaded_line( .font_family(code.mono_font_family.clone()) .text_size(code.mono_font_size) .text_color(text_color) - .child(expanded); + .child(gpui::StyledText::new(expanded).with_highlights(highlights)); let mut element = apply_generated_render_styles(gpui::div().bg(background), line.style) .h(gpui::px(code.item_height)) .w_full() @@ -490,6 +505,81 @@ fn expand_tabs(text: &str, tab_width: usize) -> String { expanded } +#[cfg(feature = "components")] +fn line_highlights( + text: &str, + tab_width: usize, + runs: &[crate::TextStyleRunNode], +) -> Vec<(Range, gpui::HighlightStyle)> { + let mut previous_end = 0; + runs.iter() + .take(512) + .filter_map(|run| { + if run.range.start.line != 0 || run.range.end.line != 0 { + return None; + } + let start = expanded_offset(text, tab_width, run.range.start.utf16_offset)?; + let end = expanded_offset(text, tab_width, run.range.end.utf16_offset)?; + if start >= end || start < previous_end { + return None; + } + previous_end = end; + let font_weight = run.font_weight.as_deref().and_then(|weight| match weight { + "thin" => Some(gpui::FontWeight::THIN), + "extra_light" => Some(gpui::FontWeight::EXTRA_LIGHT), + "light" => Some(gpui::FontWeight::LIGHT), + "normal" => Some(gpui::FontWeight::NORMAL), + "medium" => Some(gpui::FontWeight::MEDIUM), + "semibold" => Some(gpui::FontWeight::SEMIBOLD), + "bold" => Some(gpui::FontWeight::BOLD), + "extra_bold" => Some(gpui::FontWeight::EXTRA_BOLD), + "black" => Some(gpui::FontWeight::BLACK), + _other => None, + }); + let font_style = run.font_style.as_deref().and_then(|style| match style { + "normal" => Some(gpui::FontStyle::Normal), + "italic" => Some(gpui::FontStyle::Italic), + "oblique" => Some(gpui::FontStyle::Oblique), + _other => None, + }); + Some(( + start..end, + gpui::HighlightStyle { + color: run.color.map(|color| gpui::rgb(color).into()), + font_weight, + font_style, + ..Default::default() + }, + )) + }) + .collect() +} + +#[cfg(feature = "components")] +fn expanded_offset(text: &str, tab_width: usize, target: u64) -> Option { + let mut utf16 = 0; + let mut column = 0; + let mut bytes = 0; + for character in text.chars() { + if utf16 == target { + return Some(bytes); + } + utf16 += character.len_utf16() as u64; + if utf16 > target { + return None; + } + if character == '\t' { + let spaces = tab_width - column % tab_width; + bytes += spaces; + column += spaces; + } else { + bytes += character.len_utf8(); + column += 1; + } + } + (utf16 == target).then_some(bytes) +} + #[cfg(feature = "components")] fn line_number_width(total_count: usize) -> f32 { let digits = total_count.max(1).to_string().len(); @@ -580,6 +670,32 @@ mod tests { assert_eq!(expanded_columns("a\tb", 4), 5); } + #[test] + fn highlights_follow_utf16_ranges_through_tab_expansion() { + let run = crate::TextStyleRunNode { + range: crate::TextRange { + start: crate::TextPosition { + line: 0, + utf16_offset: 3, + }, + end: crate::TextPosition { + line: 0, + utf16_offset: 6, + }, + }, + color: Some(0x8250DF), + font_weight: Some("bold".to_string()), + font_style: None, + }; + let highlights = line_highlights("😀\tdef", 4, &[run]); + assert_eq!(expand_tabs("😀\tdef", 4), "😀 def"); + assert_eq!(highlights.len(), 1); + assert_eq!(highlights[0].0, 7..10); + assert_eq!(highlights[0].1.font_weight, Some(gpui::FontWeight::BOLD)); + assert_eq!(expanded_offset("😀\tdef", 4, 1), None); + assert_eq!(expanded_offset("😀\tdef", 4, 7), None); + } + #[test] fn keyboard_navigation_skips_disabled_loaded_lines() { let lines = vec![ diff --git a/apps/gpui_native/native/src/generated/schema.rs b/apps/gpui_native/native/src/generated/schema.rs index bea7448a..ec1d2e56 100644 --- a/apps/gpui_native/native/src/generated/schema.rs +++ b/apps/gpui_native/native/src/generated/schema.rs @@ -3506,6 +3506,7 @@ pub(crate) struct CodeLineComponentNode { pub(crate) id: String, pub(crate) text: String, pub(crate) number: Option, + pub(crate) style_runs: Vec, pub(crate) kind: Option, pub(crate) disabled: bool, } @@ -3520,6 +3521,7 @@ pub(crate) fn decode_generated_code_line_component<'a>( id: component_id(term)?, text: component_string_attr(term, atoms::text())?.unwrap_or_default(), number: component_non_negative_integer_attr(term, atoms::number())?, + style_runs: text_style_runs_attr(term, atoms::style_runs())?, kind: match component_enum_attr( term, atoms::kind(), diff --git a/codegen/gpui/codegen/native/component/macros.ex b/codegen/gpui/codegen/native/component/macros.ex index e5f5eea4..7505a320 100644 --- a/codegen/gpui/codegen/native/component/macros.ex +++ b/codegen/gpui/codegen/native/component/macros.ex @@ -131,6 +131,9 @@ defmodule GPUI.Codegen.Native.Component.Macros do defp component_field_type(_name, :rich_text_runs), do: quote(do: R.vec(RichTextRunNode.t())) + defp component_field_type(_name, :text_style_runs), + do: quote(do: R.vec(TextStyleRunNode.t())) + defp component_decoder_expr(:id, :string), do: quote(do: unwrap!(component_id(term))) defp component_decoder_expr(name, :required_string), @@ -232,6 +235,9 @@ defmodule GPUI.Codegen.Native.Component.Macros do defp component_decoder_expr(_name, :rich_text_runs), do: quote(do: unwrap!(decode_rich_text_runs(term))) + defp component_decoder_expr(name, :text_style_runs), + do: quote(do: unwrap!(text_style_runs_attr(term, unquote(atom_call(name))))) + defp atom_call(name), do: {{:., [], [{:__aliases__, [], [:Atoms]}, rust_atom_name(name)]}, [], []}