use gpui::{
AnyElement, App, ClipboardItem, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{
ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, Theme, TypeScale,
};
use crate::content::markdown::CodeSpan;
use crate::controls::button::Button;
use crate::data::{List, ListItem};
use crate::display::empty::{EmptyKind, EmptyState};
use crate::foundation::{Disableable, Ident, Sizable, StyledExt};
use crate::layout::{ScrollArea, ScrollAxis};
use crate::strings::{ActiveStrings, StringKey};
const DIGIT_WIDTH: f32 = 8.0;
const GUTTER_GAP: f32 = 12.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineMark {
Added,
Removed,
Changed,
Highlighted,
Error,
}
impl LineMark {
pub fn name(self) -> &'static str {
match self {
Self::Added => "added",
Self::Removed => "removed",
Self::Changed => "changed",
Self::Highlighted => "highlighted",
Self::Error => "error",
}
}
fn key(self) -> StringKey {
match self {
Self::Added => StringKey::CodeLineAdded,
Self::Removed => StringKey::CodeLineRemoved,
Self::Changed => StringKey::CodeLineChanged,
Self::Highlighted => StringKey::CodeLineHighlighted,
Self::Error => StringKey::CodeLineError,
}
}
fn colors(self, theme: &Theme) -> (gpui::Hsla, gpui::Hsla) {
let tint = match self {
Self::Added => theme.colors.success,
Self::Removed => theme.colors.danger,
Self::Changed => theme.colors.warning,
Self::Highlighted => theme.colors.accent,
Self::Error => theme.colors.danger,
};
(tint, tint.opacity(theme.effects.selected_ring_alpha))
}
fn struck(self) -> bool {
matches!(self, Self::Removed)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeLine {
pub number: usize,
pub text: SharedString,
pub spans: Vec<CodeSpan>,
pub mark: Option<LineMark>,
}
impl CodeLine {
pub fn new(number: usize, text: impl Into<SharedString>) -> Self {
Self {
number,
text: text.into(),
spans: Vec::new(),
mark: None,
}
}
pub fn spans(mut self, spans: impl IntoIterator<Item = CodeSpan>) -> Self {
self.spans = spans.into_iter().collect();
self
}
pub fn mark(mut self, mark: LineMark) -> Self {
self.mark = Some(mark);
self
}
}
#[derive(IntoElement)]
pub struct CodeView {
ident: Ident,
lines: Vec<CodeLine>,
language: Option<SharedString>,
line_numbers: bool,
visible_lines: Option<usize>,
copyable: bool,
}
impl std::fmt::Debug for CodeView {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CodeView")
.field("ident", &self.ident)
.field("lines", &self.lines.len())
.field("language", &self.language)
.field("visible_lines", &self.visible_lines)
.finish()
}
}
impl CodeView {
pub fn new(ident: impl Into<Ident>, lines: impl IntoIterator<Item = CodeLine>) -> Self {
Self {
ident: ident.into(),
lines: lines.into_iter().collect(),
language: None,
line_numbers: true,
visible_lines: None,
copyable: true,
}
}
pub fn from_text(ident: impl Into<Ident>, text: &str) -> Self {
Self::new(
ident,
text.lines()
.enumerate()
.map(|(index, line)| CodeLine::new(index + 1, line.to_string())),
)
}
pub fn language(mut self, language: impl Into<SharedString>) -> Self {
self.language = Some(language.into());
self
}
pub fn line_numbers(mut self, line_numbers: bool) -> Self {
self.line_numbers = line_numbers;
self
}
pub fn visible_lines(mut self, lines: usize) -> Self {
self.visible_lines = Some(lines);
self
}
pub fn copyable(mut self, copyable: bool) -> Self {
self.copyable = copyable;
self
}
pub fn text(&self) -> String {
self.lines
.iter()
.map(|line| line.text.as_ref())
.collect::<Vec<_>>()
.join("\n")
}
fn gutter_width(&self) -> f32 {
let widest = self
.lines
.iter()
.map(|line| line.number)
.max()
.unwrap_or(1)
.max(1)
.to_string()
.len();
widest as f32 * DIGIT_WIDTH + GUTTER_GAP
}
}
impl Sizable for CodeView {
fn control_size(self, _size: ControlSize) -> Self {
self
}
}
fn line_element(
ident: &Ident,
line: &CodeLine,
gutter: f32,
line_numbers: bool,
theme: &Theme,
cx: &App,
) -> AnyElement {
let (rail, wash) = line
.mark
.map(|mark| mark.colors(theme))
.unzip_or(theme.colors.hairline, gpui::transparent_black());
let struck = line.mark.is_some_and(LineMark::struck);
let row = div()
.row()
.items_start()
.w_full()
.h(px(theme.typography.code.line_height))
.when(line.mark.is_some(), |element| element.bg(wash))
.when(line_numbers, |element| {
element.child(
div()
.flex_none()
.w(px(gutter))
.pr(px(GUTTER_GAP / 2.0))
.text_align(gpui::TextAlign::Right)
.text_color(theme.colors.text_faint)
.child(SharedString::from(line.number.to_string())),
)
})
.child(
div()
.flex_none()
.w(px(theme.borders.thick))
.h_full()
.when(line.mark.is_some(), |element| element.bg(rail)),
)
.child(
div()
.row()
.items_baseline()
.flex_1()
.min_w_0()
.whitespace_nowrap()
.pl(px(GUTTER_GAP / 2.0))
.when(struck, |element| element.line_through())
.children(code_runs(theme, line.text.as_ref(), &line.spans)),
);
match line.mark {
Some(mark) => row
.semantic_in(
cx,
NodeSpec::new(line_id(ident, line.number), Role::Row)
.parent(ident.semantic_id())
.text(cx.strings().text(mark.key()))
.value(mark.name())
.invalid(matches!(mark, LineMark::Error)),
)
.into_any_element(),
None => row.into_any_element(),
}
}
fn line_id(ident: &Ident, number: usize) -> SharedString {
ident.child(format!("line-{number}")).semantic_id()
}
pub(crate) fn code_runs(theme: &Theme, text: &str, spans: &[CodeSpan]) -> Vec<AnyElement> {
let mut out: Vec<AnyElement> = Vec::new();
let mut cut = 0usize;
for span in spans {
if span.range.start < cut || span.range.start >= span.range.end {
continue;
}
let (Some(before), Some(inside)) = (
text.get(cut..span.range.start),
text.get(span.range.start..span.range.end),
) else {
continue;
};
if !before.is_empty() {
out.push(
div()
.flex_none()
.child(SharedString::from(before.to_string()))
.into_any_element(),
);
}
out.push(
div()
.flex_none()
.text_color(span.tone.color(theme))
.child(SharedString::from(inside.to_string()))
.into_any_element(),
);
cut = span.range.end;
}
if let Some(rest) = text.get(cut..)
&& !rest.is_empty()
{
out.push(
div()
.flex_none()
.child(SharedString::from(rest.to_string()))
.into_any_element(),
);
}
out
}
trait UnzipOr<A, B> {
fn unzip_or(self, first: A, second: B) -> (A, B);
}
impl<A, B> UnzipOr<A, B> for Option<(A, B)> {
fn unzip_or(self, first: A, second: B) -> (A, B) {
self.unwrap_or((first, second))
}
}
impl RenderOnce for CodeView {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let gutter = self.gutter_width();
let line_numbers = self.line_numbers;
let total = self.lines.len();
let body_ident = self.ident.child("lines");
let copy = self.copyable.then(|| {
let clipboard = self.text();
Button::new(self.ident.child("copy"))
.label(cx.strings().text(StringKey::Copy))
.ghost()
.control_size(ControlSize::Xs)
.semantic_parent(self.ident.semantic_id())
.disabled(clipboard.is_empty())
.on_click(move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(clipboard.clone()));
})
});
let body: AnyElement = if total == 0 {
EmptyState::new(
self.ident.child("empty"),
cx.strings().text(StringKey::CodeEmpty),
)
.kind(EmptyKind::Empty)
.into_any_element()
} else if let Some(visible) = self.visible_lines {
let lines = std::rc::Rc::new(self.lines);
let list_ident = body_ident.clone();
let theme_for_rows = theme.clone();
List::new(body_ident.clone(), total, move |index, _window, cx| {
let line = &lines[index];
ListItem::new(
line_id(&list_ident, line.number),
line_element(&list_ident, line, gutter, line_numbers, &theme_for_rows, cx),
)
})
.row_height(theme.typography.code.line_height)
.visible_lines(visible)
.into_any_element()
} else {
ScrollArea::new(body_ident.clone())
.axis(ScrollAxis::Both)
.fit_height()
.child(
div().column().children(
self.lines
.iter()
.map(|line| {
line_element(&body_ident, line, gutter, line_numbers, &theme, cx)
})
.collect::<Vec<_>>(),
),
)
.into_any_element()
};
div()
.id(self.ident.element_id())
.column()
.w_full()
.gap_token(&theme, Space::Xs)
.p_token(&theme, Space::Sm)
.radius(&theme, Radius::Card)
.frame(&theme, Surface::Raised, Elevation::Raised)
.when(self.language.is_some() || copy.is_some(), |element| {
element.child(
div()
.row()
.w_full()
.justify_between()
.type_scale(&theme, TypeScale::Caption)
.text_color(theme.colors.text_faint)
.child(div().child(self.language.clone().unwrap_or_default()))
.children(copy),
)
})
.child(
div()
.w_full()
.font_family(theme.typography.mono.clone())
.text_size(px(theme.typography.code.size))
.line_height(px(theme.typography.code.line_height))
.text_color(theme.colors.text)
.child(body),
)
.semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::Region)
.when_language(self.language)
.value(total.to_string()),
)
}
}
trait LanguageSpec {
fn when_language(self, language: Option<SharedString>) -> Self;
}
impl LanguageSpec for NodeSpec {
fn when_language(self, language: Option<SharedString>) -> Self {
match language {
Some(language) => self.text(language),
None => self,
}
}
}
trait VisibleLines {
fn visible_lines(self, lines: usize) -> Self;
}
impl VisibleLines for List {
fn visible_lines(self, lines: usize) -> Self {
self.visible_rows(lines)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::display::badge::Tone;
#[test]
fn every_mark_publishes_a_name_of_its_own() {
let names = [
LineMark::Added,
LineMark::Removed,
LineMark::Changed,
LineMark::Highlighted,
LineMark::Error,
]
.map(LineMark::name);
let mut sorted = names.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), names.len());
}
#[test]
fn a_span_naming_no_slice_of_the_line_is_skipped() {
let theme = Theme::studio_dark();
let line = CodeLine::new(1, "let x = 1;").spans([CodeSpan {
range: 40..50,
tone: Tone::Accent,
}]);
assert_eq!(code_runs(&theme, line.text.as_ref(), &line.spans).len(), 1);
}
#[test]
fn a_view_keeps_the_numbers_it_was_given() {
let view = CodeView::new(
"review.hunk",
[CodeLine::new(400, "a"), CodeLine::new(401, "b")],
);
assert_eq!(view.lines[0].number, 400);
assert_eq!(view.text(), "a\nb");
}
#[test]
fn splitting_text_numbers_from_one() {
let view = CodeView::from_text("file", "first\nsecond\nthird");
assert_eq!(view.lines.len(), 3);
assert_eq!(view.lines[2].number, 3);
}
}