use std::rc::Rc;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Radius, Space, TextTone, Theme, TypeScale};
use crate::foundation::{
Disableable, FocusRing, Ident, Pressable, StyledExt, text as foundation_text,
};
const RAIL_WIDTH: f32 = 20.0;
const MARKER_SIZE: f32 = 10.0;
type JumpHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryEntry {
id: SharedString,
label: SharedString,
description: Option<SharedString>,
time: Option<SharedString>,
source: Option<SharedString>,
unavailable: Option<SharedString>,
}
impl HistoryEntry {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
description: None,
time: None,
source: None,
unavailable: None,
}
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn time(mut self, time: impl Into<SharedString>) -> Self {
self.time = Some(time.into());
self
}
pub fn source(mut self, source: impl Into<SharedString>) -> Self {
self.source = Some(source.into());
self
}
pub fn unavailable(mut self, reason: impl Into<SharedString>) -> Self {
self.unavailable = Some(reason.into());
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
pub fn label(&self) -> &SharedString {
&self.label
}
}
#[derive(IntoElement)]
pub struct UndoHistory {
ident: Ident,
label: SharedString,
entries: Vec<HistoryEntry>,
current: Option<SharedString>,
disabled: bool,
on_jump: Option<JumpHandler>,
}
impl std::fmt::Debug for UndoHistory {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("UndoHistory")
.field("ident", &self.ident)
.field("entries", &self.entries.len())
.field("current", &self.current)
.field("disabled", &self.disabled)
.field("has_handler", &self.on_jump.is_some())
.finish()
}
}
impl UndoHistory {
pub fn new(ident: impl Into<Ident>, label: impl Into<SharedString>) -> Self {
Self {
ident: ident.into(),
label: label.into(),
entries: Vec::new(),
current: None,
disabled: false,
on_jump: None,
}
}
pub fn entry(mut self, entry: HistoryEntry) -> Self {
self.entries.push(entry);
self
}
pub fn entries(mut self, entries: impl IntoIterator<Item = HistoryEntry>) -> Self {
self.entries.extend(entries);
self
}
pub fn current(mut self, id: impl Into<SharedString>) -> Self {
self.current = Some(id.into());
self
}
pub fn on_jump(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_jump = Some(Rc::new(handler));
self
}
}
impl Disableable for UndoHistory {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl RenderOnce for UndoHistory {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let count = self.entries.len();
let entries = Rc::new(self.entries);
let current = self.current;
let handler = self.on_jump.filter(|_| !self.disabled);
let mut list = div().column().w_full();
for (index, entry) in entries.iter().enumerate() {
list = list.child(entry_element(
&self.ident,
&theme,
entry,
index + 1 < count,
current.as_ref(),
self.disabled,
handler.as_ref(),
cx,
));
}
if let Some(handler) = handler {
let keyboard = Rc::clone(&handler);
let entries = Rc::clone(&entries);
list = list.on_key_down(move |event, window, cx| {
let current_index = current
.as_ref()
.and_then(|current| entries.iter().position(|entry| &entry.id == current));
let target = match event.keystroke.key.as_str() {
"up" => jump_target(
&entries,
current_index.and_then(|index| index.checked_sub(1)),
-1,
current.as_ref(),
),
"down" => jump_target(
&entries,
current_index.map(|index| index + 1),
1,
current.as_ref(),
),
"home" => jump_target(&entries, Some(0), 1, current.as_ref()),
"end" => {
jump_target(&entries, entries.len().checked_sub(1), -1, current.as_ref())
}
_ => None,
};
if let Some(target) = target {
keyboard(target, window, cx);
cx.stop_propagation();
}
});
}
list.semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::List)
.text(self.label)
.value(count.to_string()),
)
}
}
#[allow(clippy::too_many_arguments)]
fn entry_element(
history: &Ident,
theme: &Theme,
entry: &HistoryEntry,
continues: bool,
current: Option<&SharedString>,
history_disabled: bool,
handler: Option<&JumpHandler>,
cx: &mut App,
) -> AnyElement {
let ident = history.child(entry.id.as_ref());
let selected = current == Some(&entry.id);
let disabled = history_disabled || entry.unavailable.is_some();
let actionable = !disabled && !selected && handler.is_some();
let marker_color = if selected {
theme.colors.accent
} else {
theme.colors.hairline
};
let rail = div()
.w(px(RAIL_WIDTH))
.flex_none()
.column()
.items_center()
.child(
div()
.mt(px(5.0))
.size(px(MARKER_SIZE))
.rounded_full()
.border(px(theme.borders.hairline))
.border_color(marker_color)
.when(selected, |element| element.bg(marker_color)),
)
.when(continues, |element| {
element.child(
div()
.mt(px(3.0))
.w(px(theme.borders.hairline))
.flex_1()
.min_h(px(theme.space(Space::Lg)))
.bg(theme.colors.hairline),
)
});
let mut metadata = div().row().flex_wrap().gap_token(theme, Space::Sm);
if let Some(source) = &entry.source {
metadata = metadata.child(
foundation_text(theme, TypeScale::Caption, source.clone())
.text_tone(theme, TextTone::Muted)
.semantic_in(
cx,
NodeSpec::new(ident.child("source").semantic_id(), Role::Text)
.parent(ident.semantic_id())
.text(source.clone()),
),
);
}
if let Some(time) = &entry.time {
metadata = metadata.child(
foundation_text(theme, TypeScale::Caption, time.clone())
.text_tone(theme, TextTone::Faint)
.semantic_in(
cx,
NodeSpec::new(ident.child("time").semantic_id(), Role::Text)
.parent(ident.semantic_id())
.text(time.clone()),
),
);
}
let mut content = div()
.column()
.flex_1()
.min_w_0()
.gap(px(2.0))
.pb(px(theme.space(Space::Md)))
.child(
foundation_text(theme, TypeScale::Label, entry.label.clone()).text_tone(
theme,
if disabled {
TextTone::Faint
} else {
TextTone::Primary
},
),
)
.children(entry.description.clone().map(|description| {
foundation_text(theme, TypeScale::Body, description).text_tone(theme, TextTone::Muted)
}))
.when(entry.source.is_some() || entry.time.is_some(), |element| {
element.child(metadata)
});
if let Some(reason) = &entry.unavailable {
content = content.child(
foundation_text(theme, TypeScale::Caption, reason.clone())
.text_color(theme.colors.warning)
.semantic_in(
cx,
NodeSpec::new(ident.child("reason").semantic_id(), Role::Text)
.parent(ident.semantic_id())
.text(reason.clone()),
),
);
}
let mut row = div()
.id(ident.element_id())
.row()
.items_stretch()
.w_full()
.gap_token(theme, Space::Sm)
.px_token(theme, Space::Sm)
.pt(px(theme.space(Space::Sm)))
.radius(theme, Radius::Control)
.when(selected, |element| element.bg(theme.colors.selected))
.when(history_disabled, |element| {
element.opacity(theme.opacity.disabled)
})
.child(rail)
.child(content);
if !disabled && handler.is_some() {
row = row.tab_index(0).pressable(cx).focus_ring(theme);
}
if actionable {
row = row
.cursor_pointer()
.hover(|style| style.bg(theme.colors.hover));
}
if let Some(handler) = handler {
if actionable {
let click = Rc::clone(handler);
let id = entry.id.clone();
row = row.on_click(move |_, window, cx| click(id.clone(), window, cx));
}
if !disabled {
let keyboard = Rc::clone(handler);
let id = entry.id.clone();
row = row.on_key_down(move |event, window, cx| {
if matches!(event.keystroke.key.as_str(), "enter" | "space") && !selected {
keyboard(id.clone(), window, cx);
cx.stop_propagation();
}
});
}
}
let mut spec = NodeSpec::new(ident.semantic_id(), Role::Row)
.parent(history.semantic_id())
.text(entry.label.clone())
.selected(selected)
.disabled(disabled);
if let Some(description) = &entry.description {
spec = spec.description(description.clone());
}
row.semantic_in(cx, spec).into_any_element()
}
fn jump_target(
entries: &[HistoryEntry],
start: Option<usize>,
delta: isize,
current: Option<&SharedString>,
) -> Option<SharedString> {
let mut index = start? as isize;
while index >= 0 && (index as usize) < entries.len() {
let entry = &entries[index as usize];
if entry.unavailable.is_none() && current != Some(&entry.id) {
return Some(entry.id.clone());
}
index += delta;
}
None
}