use crate::{
model::session::ChatSession,
view::{
component::menu::{self, Menu},
root::{Cydonia, NewBoard, Pane},
sidebar::Renaming,
},
};
use artifact::board::Card;
use bezel::{
gpui::{
self, AnyElement, App, ClipboardItem, Context, Div, Entity, Focusable as _, FontWeight,
KeyBinding, 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},
widgets::Buttons,
},
};
use markdown::Typography;
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),
}
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 move_card(&mut self, card: &str, to: &str, cx: &mut Context<Self>) {
self.commit(cx);
let (card, to) = (card.to_owned(), to.to_owned());
self.workspace.update(cx, |workspace, cx| {
let moved = workspace
.active_board_mut()
.is_some_and(|board| board.move_card(&card, &to));
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> = (0..ids.len())
.map(|ix| self.column(&ids, ix, window, cx))
.collect();
div()
.flex_1()
.min_h_0()
.on_action(cx.listener(Self::commit_card))
.on_action(cx.listener(Self::dismiss_card))
.child(
scroll::pane("board", Axes::Horizontal)
.size_full()
.flex()
.flex_row()
.gap(px(10.))
.px(px(16.))
.py(px(16.))
.children(columns)
.child(self.new_column_lane(cx)),
)
.into_any_element()
}
fn column(
&self,
ids: &[String],
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let theme = Theme::of(cx).clone();
let id = ids[ix].clone();
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 left = ix.checked_sub(1).map(|n| ids[n].clone());
let right = ids.get(ix + 1).cloned();
let mut rows: Vec<AnyElement> = cards
.iter()
.map(|card| self.card(card, left.as_deref(), right.as_deref(), window, cx))
.collect();
if matches!(&self.editing, Some(Editing::New(at)) if *at == id) {
rows.push(self.card_editor(cx));
}
div()
.flex_none()
.w(px(COLUMN_WIDTH))
.h_full()
.flex()
.flex_col()
.gap(px(8.))
.child(self.column_header(&id, name, cards.len(), cx))
.child(
scroll::pane(SharedString::from(format!("column-{id}")), Axes::Vertical)
.flex_1()
.min_h_0()
.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);
})),
),
)
.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,
left: Option<&str>,
right: 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());
div()
.id(SharedString::from(format!("card-{id}")))
.group("card")
.flex_none()
.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(left.map(|to| {
let (card, to) = (id.to_owned(), to.to_owned());
self.card_action("left", id, icons::arrows::ChevronLeft, cx)
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.move_card(&card, &to, cx);
}))
}))
.children(right.map(|to| {
let (card, to) = (id.to_owned(), to.to_owned());
self.card_action("right", id, icons::arrows::ChevronRight, cx)
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.move_card(&card, &to, cx);
}))
}))
.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_click(cx.listener(move |this, _, window, cx| {
this.edit(Editing::Card(opened.clone()), window, cx);
}))
.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()
}
}