use crate::{
model::session::ChatSession,
view::{
component::menu::{self, Menu},
root::{Cydonia, NewBoard, Pane},
sidebar::Renaming,
},
};
use artifact::board::Card;
use bezel::ui::scroll as scrollbars;
use bezel::{
gpui::{
self, AnyElement, App, ClipboardItem, Context, Div, DragMoveEvent, Entity, Focusable as _,
FontWeight, KeyBinding, Render, ScrollHandle, SharedString, Stateful, Window, actions, div,
prelude::*, px,
},
motion::Painter,
theme::{TextStyle, Theme, Typeset},
ui::{
icons,
input::{self, Shape, TextField},
loaders,
menu::Item,
popover,
scroll::{self, Axes, DriftState},
widgets::Buttons,
},
};
use markdown::Typography;
use std::{cell::RefCell, collections::HashMap};
actions!(cydonia_board, [CommitCard, DismissCard]);
const KEY_CONTEXT: &str = "CydoniaCard";
const COLUMN_WIDTH: f32 = 272.;
const CARD_MAX_HEIGHT: f32 = 140.;
const CARD_EDITOR_MAX_ROWS: usize = 512;
const CARD_TEXT_SCALE: f32 = TextStyle::Callout.size() / TextStyle::Body.size();
pub fn bindings() -> Vec<KeyBinding> {
let ctx = Some(KEY_CONTEXT);
vec![
KeyBinding::new("enter", CommitCard, ctx),
KeyBinding::new("shift-enter", input::InsertNewline, ctx),
KeyBinding::new("escape", DismissCard, ctx),
]
}
pub fn field(cx: &mut App) -> Entity<TextField> {
cx.new(|cx| {
TextField::new(cx)
.with_shape(Shape::Grow {
min: 2,
max: CARD_EDITOR_MAX_ROWS,
})
.with_key_context(KEY_CONTEXT)
.with_placeholder("what needs doing…")
})
}
fn card_body(text: &str, window: &mut Window, cx: &mut App) -> AnyElement {
markdown::render_with(
&markdown::parse(text),
markdown::Editing {
caption: markdown::Caption::Hidden,
typography: Some(Typography::of(cx).scaled(CARD_TEXT_SCALE)),
..Default::default()
},
window,
cx,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Editing {
New(String),
Card(String),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mark {
Above,
Top,
Below,
Flow,
}
#[derive(Clone)]
pub struct CardDrag(String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Landing {
pub column: String,
pub before: Option<String>,
}
#[derive(Default)]
pub struct Lanes(RefCell<HashMap<String, (ScrollHandle, DriftState)>>);
impl Lanes {
fn of(&self, id: &str) -> (ScrollHandle, DriftState) {
self.0
.borrow_mut()
.entry(id.to_owned())
.or_default()
.clone()
}
}
pub struct HeldCard {
text: String,
}
impl Render for HeldCard {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx).clone();
div()
.w(px(COLUMN_WIDTH))
.max_h(px(CARD_MAX_HEIGHT))
.overflow_hidden()
.p(px(10.))
.rounded(px(Theme::control_radius()))
.border_1()
.border_color(theme.accent)
.bg(theme.surface_raised)
.child(card_body(&self.text, window, cx))
}
}
impl Cydonia {
pub fn show_pane(&mut self, pane: Pane, cx: &mut Context<Self>) {
self.commit(cx);
self.pane = pane;
cx.notify();
}
pub(crate) fn new_board_action(
&mut self,
_: &NewBoard,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(project) = self.workspace.read(cx).active else {
return;
};
self.ask_new_board(project, window, cx);
}
pub(crate) fn open_board(&mut self, project: usize, ix: usize, cx: &mut Context<Self>) {
self.commit(cx);
self.workspace
.update(cx, |workspace, cx| workspace.open_board(project, ix, cx));
self.pane = Pane::Board;
cx.notify();
}
fn edit(&mut self, at: Editing, window: &mut Window, cx: &mut Context<Self>) {
self.commit(cx);
let text = match &at {
Editing::New(_) => String::new(),
Editing::Card(id) => self
.workspace
.read(cx)
.active_board()
.and_then(|board| board.card(id))
.map(|card| card.text.clone())
.unwrap_or_default(),
};
self.card_field
.update(cx, |field, cx| field.set_content(text, cx));
self.editing = Some(at);
window.focus(&self.card_field.read(cx).focus_handle(cx), cx);
cx.notify();
}
pub(crate) fn commit(&mut self, cx: &mut Context<Self>) {
self.commit_cell(cx);
self.rest_ribbon(cx);
let Some(at) = self.editing.take() else {
return;
};
let text = self.card_field.read(cx).content().trim().to_owned();
self.card_field.update(cx, |field, cx| field.clear(cx));
self.workspace.update(cx, |workspace, cx| {
let Some(board) = workspace.active_board_mut() else {
return;
};
match at {
Editing::New(column) => {
if !text.is_empty() {
board.add_card(&column, text);
}
}
Editing::Card(id) => {
if text.is_empty() {
board.remove_card(&id);
} else {
board.rewrite_card(&id, &text);
}
}
}
workspace.save_board();
cx.notify();
});
}
pub(crate) fn drop_stale_edit(&mut self, cx: &mut Context<Self>) {
let Some(at) = self.editing.clone() else {
return;
};
let board = self.workspace.read(cx).active_board();
let alive = match &at {
Editing::New(column) => board.is_some_and(|board| board.column(column).is_some()),
Editing::Card(card) => board.is_some_and(|board| board.card(card).is_some()),
};
if !alive {
self.editing = None;
self.card_field.update(cx, |field, cx| field.clear(cx));
}
}
fn commit_card(&mut self, _: &CommitCard, _: &mut Window, cx: &mut Context<Self>) {
self.commit(cx);
cx.notify();
}
fn dismiss_card(&mut self, _: &DismissCard, _: &mut Window, cx: &mut Context<Self>) {
self.editing = None;
self.card_field.update(cx, |field, cx| field.clear(cx));
cx.notify();
}
fn aim_card(&mut self, landing: Option<Landing>, cx: &mut Context<Self>) {
if self.landing != landing {
self.landing = landing;
cx.notify();
}
}
fn drop_card(&mut self, card: &str, column: &str, cx: &mut Context<Self>) {
let before = self
.landing
.take()
.filter(|landing| landing.column == column)
.and_then(|landing| landing.before);
self.commit(cx);
let (card, column) = (card.to_owned(), column.to_owned());
self.workspace.update(cx, |workspace, cx| {
let moved = workspace
.active_board_mut()
.is_some_and(|board| board.move_card_before(&card, &column, before.as_deref()));
if moved {
workspace.save_board();
}
cx.notify();
});
cx.notify();
}
pub(crate) fn delete_card(&mut self, card: &str, cx: &mut Context<Self>) {
self.commit(cx);
let card = card.to_owned();
self.workspace.update(cx, |workspace, cx| {
let gone = workspace
.active_board_mut()
.and_then(|board| board.remove_card(&card))
.is_some();
if gone {
workspace.save_board();
}
cx.notify();
});
cx.notify();
}
fn new_column(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.commit(cx);
let id = self
.workspace
.update(cx, |workspace, cx| workspace.new_column(cx));
if let Some(id) = id {
self.start_rename(Renaming::Column(id), window, cx);
}
}
fn drop_column(&mut self, id: &str, cx: &mut Context<Self>) {
self.commit(cx);
let id = id.to_owned();
self.workspace
.update(cx, |workspace, cx| workspace.remove_column(&id, cx));
cx.notify();
}
fn copy_card(&mut self, card: &str, cx: &mut Context<Self>) {
let Some(text) = self
.workspace
.read(cx)
.active_board()
.and_then(|board| board.card(card))
.map(|card| card.text.clone())
else {
return;
};
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn dispatch_card(&mut self, card: &str, cx: &mut Context<Self>) {
self.commit(cx);
let card = card.to_owned();
self.workspace.update(cx, |workspace, cx| {
let text = workspace
.active_board()
.and_then(|board| board.card(&card))
.map(|card| card.text.clone());
let (Some(text), Some(entry)) = (text, workspace.preferred_agent()) else {
return;
};
let Some(record) = workspace
.new_session(entry, Some(text), cx)
.and_then(|id| workspace.mint_record(id))
else {
return;
};
if workspace
.active_board_mut()
.is_some_and(|board| board.dispatch_card(&card, record))
{
workspace.save_board();
}
});
cx.notify();
}
fn card_menu(&self, card: &str, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.menu.as_ref() != Some(&Menu::Card(card.to_owned())) {
return None;
}
let (copied, doomed) = (card.to_owned(), card.to_owned());
let rows = vec![
menu::row(
Item::action("Copy text").with_icon(icons::text::Copy),
move |this, _, cx| this.copy_card(&copied, cx),
),
menu::row(
Item::action("Delete").with_icon(icons::files::Trash),
move |this, _, cx| this.ask_delete_card(&doomed, cx),
),
];
let id = SharedString::from(format!("card-menu-card-{card}"));
Some(popover::anchored_menu_below(
id.clone(),
self.menu_card(id, rows, cx),
None,
))
}
fn card_session<'a>(&self, card: &Card, cx: &'a App) -> Option<&'a ChatSession> {
let record = card.session.as_deref()?;
self.workspace.read(cx).session_by_record(record)
}
pub fn board(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let Some(board) = self.workspace.read(cx).active_board() else {
return div().flex_1().into_any_element();
};
let ids: Vec<String> = board
.columns
.iter()
.map(|column| column.id.clone())
.collect();
let columns: Vec<AnyElement> = ids.iter().map(|id| self.column(id, window, cx)).collect();
div()
.flex_1()
.min_h_0()
.relative()
.on_action(cx.listener(Self::commit_card))
.on_action(cx.listener(Self::dismiss_card))
.on_drag_move(cx.listener(|this, event: &DragMoveEvent<CardDrag>, _, cx| {
this.board_drift.aim(event.event.position);
this.aim_card(None, cx);
}))
.on_drop(cx.listener(|this, _: &CardDrag, _, cx| this.aim_card(None, cx)))
.child(
scroll::pane("board", Axes::Horizontal)
.size_full()
.flex()
.flex_row()
.gap(px(10.))
.px(px(16.))
.pt(px(16.))
.track_scroll(&self.board_scroll)
.children(columns)
.child(self.new_column_lane(cx)),
)
.child(scrollbars::Overlay::new(
"board-bar",
&self.board_scroll,
bezel::gpui::Axis::Horizontal,
))
.child(scroll::drift(
&self.board_scroll,
&self.board_drift,
Axes::Horizontal,
))
.into_any_element()
}
fn column(&self, id: &str, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let id = id.to_owned();
let Some((name, cards)) = self
.workspace
.read(cx)
.active_board()
.and_then(|board| board.column(&id))
.map(|column| {
let cards: Vec<String> = column.cards.iter().map(|card| card.id.clone()).collect();
(column.name.clone(), cards)
})
else {
return div().into_any_element();
};
let mut rows: Vec<AnyElement> = cards
.iter()
.enumerate()
.map(|(at, card)| {
let next = cards.get(at + 1).map(String::as_str);
self.card(card, &id, at == 0, next, window, cx)
})
.collect();
if cards.is_empty() && self.aimed_at(&id, cx) {
rows.push(self.landing_mark(Mark::Flow, cx));
}
if matches!(&self.editing, Some(Editing::New(at)) if *at == id) {
rows.push(self.card_editor(cx));
}
let lane = id.clone();
let taken = id.clone();
let (scroll, drift) = self.lanes.of(&id);
let bar_id = format!("lane-bar-{id}");
div()
.flex_none()
.w(px(COLUMN_WIDTH))
.h_full()
.flex()
.flex_col()
.gap(px(8.))
.on_drag_move(cx.listener({
let drift = drift.clone();
move |this, event: &DragMoveEvent<CardDrag>, _, cx| {
if !event.bounds.contains(&event.event.position) {
return;
}
drift.aim(event.event.position);
this.aim_card(
Some(Landing {
column: lane.clone(),
before: None,
}),
cx,
);
}
}))
.on_drop(cx.listener(move |this, drag: &CardDrag, _, cx| {
this.drop_card(&drag.0, &taken, cx);
}))
.child(self.column_header(&id, name, cards.len(), cx))
.child(
div()
.relative()
.flex_1()
.min_h_0()
.child(
scroll::pane(SharedString::from(format!("column-{id}")), Axes::Vertical)
.size_full()
.track_scroll(&scroll)
.flex()
.flex_col()
.gap(px(8.))
.children(rows)
.child(
theme
.ghost(SharedString::from(format!("add-card-{id}")))
.flex_none()
.px(px(8.))
.py(px(6.))
.gap(px(6.))
.child(
icons::icon(icons::math::Plus)
.size(px(12.))
.text_color(theme.text_faint),
)
.child(
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_faint)
.child("Add a card"),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.edit(Editing::New(id.clone()), window, cx);
})),
),
)
.child(scroll::drift(&scroll, &drift, Axes::Vertical))
.child(scrollbars::Overlay::new(
bar_id,
&scroll,
bezel::gpui::Axis::Vertical,
)),
)
.into_any_element()
}
fn column_header(
&self,
id: &str,
name: String,
count: usize,
cx: &mut Context<Self>,
) -> AnyElement {
let theme = Theme::of(cx).clone();
let row = div()
.flex_none()
.px(px(4.))
.flex()
.flex_row()
.items_center()
.gap(px(6.))
.text_style(TextStyle::Subheadline);
if matches!(&self.renaming, Some(Renaming::Column(at)) if at == id) {
return row.child(self.name_field(cx)).into_any_element();
}
let named = id.to_owned();
let dropped = id.to_owned();
row.group("column")
.child(
div()
.id(SharedString::from(format!("column-name-{id}")))
.font_weight(FontWeight::MEDIUM)
.text_color(theme.text_muted)
.cursor_pointer()
.child(name)
.on_click(cx.listener(move |this, _, window, cx| {
this.start_rename(Renaming::Column(named.clone()), window, cx);
})),
)
.child(div().text_color(theme.text_faint).child(count.to_string()))
.child(div().flex_1())
.children((count == 0).then(|| {
theme
.ghost(SharedString::from(format!("column-delete-{id}")))
.invisible()
.group_hover("column", |el| el.visible())
.p(px(3.))
.child(
icons::icon(icons::files::Trash)
.size(px(12.))
.text_color(theme.text_faint),
)
.on_click(cx.listener(move |this, _, _, cx| {
this.drop_column(&dropped, cx);
}))
}))
.into_any_element()
}
fn new_column_lane(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
div()
.flex_none()
.w(px(COLUMN_WIDTH))
.h_full()
.child(
theme
.ghost("add-column")
.flex_none()
.px(px(8.))
.py(px(6.))
.gap(px(6.))
.child(
icons::icon(icons::math::Plus)
.size(px(12.))
.text_color(theme.text_faint),
)
.child(
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_faint)
.child("Add a column"),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.new_column(window, cx);
})),
)
.into_any_element()
}
fn card(
&self,
id: &str,
column: &str,
first: bool,
next: Option<&str>,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
if matches!(&self.editing, Some(Editing::Card(at)) if at == id) {
return self.card_editor(cx);
}
let theme = Theme::of(cx).clone();
let painter = Painter::of(cx);
let Some((card, handle)) = self
.workspace
.read(cx)
.active_board()
.and_then(|board| board.card(id).map(|card| (card, board.handle_of(card))))
else {
return div().into_any_element();
};
let text = card.text.clone();
let chat = self.card_session(card, cx);
let live = chat.map(|chat| chat.id);
let sessions = self.workspace.read(cx).settings.features.sessions;
let running = chat.is_some_and(|chat| chat.streaming);
let orb = running.then(|| {
loaders::orb(
loaders::Orb::Cluster,
SharedString::from(format!("card-orb-{id}")),
12.,
&theme,
painter,
cx,
)
.into_any_element()
});
let (opened, run) = (id.to_owned(), id.to_owned());
let ahead = cx.has_active_drag()
&& self
.landing
.as_ref()
.is_some_and(|at| at.before.as_deref() == Some(id));
let behind = next.is_none() && self.aimed_at(column, cx);
let (viewport, _) = self.lanes.of(column);
div()
.id(SharedString::from(format!("card-{id}")))
.group("card")
.flex_none()
.relative()
.p(px(10.))
.rounded(px(Theme::control_radius()))
.border_1()
.border_color(theme.border)
.bg(theme.surface_raised)
.cursor_pointer()
.hover(|el| el.border_color(theme.text_faint))
.flex()
.flex_col()
.gap(px(6.))
.child(
div()
.flex()
.flex_row()
.items_start()
.gap(px(6.))
.child(
div()
.flex_1()
.min_w_0()
.max_h(px(CARD_MAX_HEIGHT))
.overflow_hidden()
.child(card_body(&text, window, cx)),
)
.child(
self.menu_button(
SharedString::from(format!("card-menu-{id}")),
Some("card"),
icons::icon(icons::layout::Ellipsis)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Card(id.to_owned()),
cx,
)
.children(self.card_menu(id, cx)),
),
)
.child(
div()
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.children(handle.map(|handle| {
div()
.flex_none()
.text_style(TextStyle::Caption)
.font_family(theme.font_mono.clone())
.text_color(theme.text_faint)
.child(handle)
}))
.children(orb)
.child(div().flex_1())
.child(
div()
.invisible()
.group_hover("card", |el| el.visible())
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.children(sessions.then(|| {
match live {
Some(session) => self
.card_action("open", id, icons::social::MessageCircle, cx)
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.select_session(session, cx);
this.show_pane(Pane::Chat, cx);
})),
None => self
.card_action("run", id, icons::multimedia::Play, cx)
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.dispatch_card(&run, cx);
})),
}
})),
),
)
.on_drag(CardDrag(id.to_owned()), move |_, _, _, cx| {
let text = text.clone();
cx.new(|_| HeldCard { text })
})
.on_drag_move(cx.listener({
let (column, card, next) =
(column.to_owned(), id.to_owned(), next.map(str::to_owned));
move |this, event: &DragMoveEvent<CardDrag>, _, cx| {
let at = event.event.position;
if !event.bounds.contains(&at) || !viewport.bounds().contains(&at) {
return;
}
let before = match at.y < event.bounds.center().y {
true => Some(card.clone()),
false => next.clone(),
};
this.aim_card(
Some(Landing {
column: column.clone(),
before,
}),
cx,
);
}
}))
.on_click(cx.listener(move |this, _, window, cx| {
this.edit(Editing::Card(opened.clone()), window, cx);
}))
.children(
ahead.then(|| self.landing_mark(if first { Mark::Top } else { Mark::Above }, cx)),
)
.children(behind.then(|| self.landing_mark(Mark::Below, cx)))
.into_any_element()
}
fn aimed_at(&self, column: &str, cx: &App) -> bool {
cx.has_active_drag()
&& self
.landing
.as_ref()
.is_some_and(|at| at.column == column && at.before.is_none())
}
fn landing_mark(&self, at: Mark, cx: &Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let mark = div().h(px(2.)).rounded_full().bg(theme.accent);
if at == Mark::Flow {
return mark.flex_none().into_any_element();
}
let mark = mark.absolute().left_0().right_0();
match at {
Mark::Above => mark.top(px(-5.)),
Mark::Top => mark.top_0(),
_ => mark.bottom(px(-5.)),
}
.into_any_element()
}
fn card_action(
&self,
name: &'static str,
id: &str,
glyph: &'static [u8],
cx: &Context<Self>,
) -> Stateful<Div> {
let theme = Theme::of(cx).clone();
theme
.ghost(SharedString::from(format!("card-{name}-{id}")))
.p(px(3.))
.child(
icons::icon(glyph)
.size(px(12.))
.text_color(theme.text_faint),
)
}
fn card_editor(&self, cx: &Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
div()
.flex_none()
.p(px(8.))
.rounded(px(Theme::control_radius()))
.border_1()
.border_color(theme.accent)
.bg(theme.surface_raised)
.flex()
.flex_col()
.gap(px(4.))
.child(self.card_field.clone())
.child(
div()
.text_style(TextStyle::Subheadline)
.font_family(theme.font_mono.clone())
.text_color(theme.text_faint)
.child("enter file · esc cancel"),
)
.on_mouse_down_out(cx.listener(|this, _, _, cx| this.commit(cx)))
.into_any_element()
}
}