Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/gpui/lib/gpui/schema/component/docs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion apps/gpui_components/lib/gpui/ui.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
38 changes: 38 additions & 0 deletions apps/gpui_components/lib/gpui/ui/collection/validation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/gpui_components/native/src/generated/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3506,6 +3506,7 @@ pub(crate) struct CodeLineComponentNode {
pub(crate) id: String,
pub(crate) text: String,
pub(crate) number: Option<u64>,
pub(crate) style_runs: Vec<TextStyleRunNode>,
pub(crate) kind: Option<String>,
pub(crate) disabled: bool,
}
Expand All @@ -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(),
Expand Down
62 changes: 62 additions & 0 deletions apps/gpui_components/test/test/gpui/ui/data_views_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"})

Expand Down
122 changes: 119 additions & 3 deletions apps/gpui_native/native/src/element/component/code_viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ impl ComponentCodeViewer {
struct CodeLine {
id: String,
text: String,
style_runs: Vec<crate::TextStyleRunNode>,
number: Option<u64>,
kind: String,
disabled: bool,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -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()
Expand Down Expand Up @@ -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<usize>, 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<usize> {
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();
Expand Down Expand Up @@ -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![
Expand Down
2 changes: 2 additions & 0 deletions apps/gpui_native/native/src/generated/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3506,6 +3506,7 @@ pub(crate) struct CodeLineComponentNode {
pub(crate) id: String,
pub(crate) text: String,
pub(crate) number: Option<u64>,
pub(crate) style_runs: Vec<TextStyleRunNode>,
pub(crate) kind: Option<String>,
pub(crate) disabled: bool,
}
Expand All @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions codegen/gpui/codegen/native/component/macros.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)]}, [], []}

Expand Down
Loading