feat: add edit-mode commands and refactor modal UI
Introduces modal edit navigation commands and a dedicated keymap context, supports opening tasks directly in edit mode, and reorganizes the task detail modal into reusable components with sectioned render/state helpers to reduce duplication.
This commit is contained in:
+1
-1
@@ -287,7 +287,7 @@ impl App {
|
|||||||
cx.subscribe(&task_table_events, |app, _table, event, cx| match event {
|
cx.subscribe(&task_table_events, |app, _table, event, cx| match event {
|
||||||
TaskTableEvent::OpenTask(task_id) => {
|
TaskTableEvent::OpenTask(task_id) => {
|
||||||
if !app.task_detail_modal.read(cx).is_open() {
|
if !app.task_detail_modal.read(cx).is_open() {
|
||||||
app.open_task_detail(*task_id, None, cx);
|
app.open_task_detail(*task_id, false, None, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
use gpui::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::components::label::Label;
|
||||||
|
use crate::theme::{ActiveTheme, Theme};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ActionButtonVariant {
|
||||||
|
Normal,
|
||||||
|
Danger,
|
||||||
|
Ghost,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, IntoElement)]
|
||||||
|
pub struct ActionButton {
|
||||||
|
id: Option<gpui::ElementId>,
|
||||||
|
label: gpui::SharedString,
|
||||||
|
variant: ActionButtonVariant,
|
||||||
|
enabled: bool,
|
||||||
|
on_click:
|
||||||
|
Option<Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionButton {
|
||||||
|
pub fn new(label: impl Into<gpui::SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: None,
|
||||||
|
label: label.into(),
|
||||||
|
variant: ActionButtonVariant::Normal,
|
||||||
|
enabled: true,
|
||||||
|
on_click: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
|
||||||
|
self.id = Some(id.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn variant(mut self, variant: ActionButtonVariant) -> Self {
|
||||||
|
self.variant = variant;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enabled(mut self, enabled: bool) -> Self {
|
||||||
|
self.enabled = enabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_click(
|
||||||
|
mut self,
|
||||||
|
handler: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_click = Some(Arc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for ActionButton {
|
||||||
|
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let (bg, fg, hover_bg) = match self.variant {
|
||||||
|
ActionButtonVariant::Normal => (theme.raised, theme.foreground, theme.hover),
|
||||||
|
ActionButtonVariant::Danger => (
|
||||||
|
theme.error,
|
||||||
|
theme.selection_foreground,
|
||||||
|
Theme::alpha(theme.error, 0.8),
|
||||||
|
),
|
||||||
|
ActionButtonVariant::Ghost => (gpui::rgba(0x00000000), theme.foreground, theme.hover),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(id) = self.id {
|
||||||
|
let mut button = gpui::div()
|
||||||
|
.id(id)
|
||||||
|
.px(gpui::rems(0.75))
|
||||||
|
.py(gpui::rems(0.35))
|
||||||
|
.rounded_md()
|
||||||
|
.text_sm();
|
||||||
|
|
||||||
|
match self.variant {
|
||||||
|
ActionButtonVariant::Normal | ActionButtonVariant::Ghost => {
|
||||||
|
button = button.border_1().border_color(theme.divider);
|
||||||
|
}
|
||||||
|
ActionButtonVariant::Danger => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.enabled {
|
||||||
|
button = button.bg(bg).text_color(fg);
|
||||||
|
if let Some(on_click) = self.on_click {
|
||||||
|
button = button
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(move |s| s.bg(hover_bg))
|
||||||
|
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
|
||||||
|
(on_click)(event, window, app);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button = button.bg(bg).text_color(theme.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return button.child(Label::new(self.label)).into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut button = gpui::div()
|
||||||
|
.px(gpui::rems(0.75))
|
||||||
|
.py(gpui::rems(0.35))
|
||||||
|
.rounded_md()
|
||||||
|
.text_sm();
|
||||||
|
|
||||||
|
match self.variant {
|
||||||
|
ActionButtonVariant::Normal | ActionButtonVariant::Ghost => {
|
||||||
|
button = button.border_1().border_color(theme.divider);
|
||||||
|
}
|
||||||
|
ActionButtonVariant::Danger => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.enabled {
|
||||||
|
button = button.bg(bg).text_color(fg);
|
||||||
|
if let Some(on_click) = self.on_click {
|
||||||
|
button = button
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(move |s| s.bg(hover_bg))
|
||||||
|
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
|
||||||
|
(on_click)(event, window, app);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button = button.bg(bg).text_color(theme.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.child(Label::new(self.label)).into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{Corner, anchored, deferred, px, point};
|
use gpui::{Corner, anchored, deferred, point, px};
|
||||||
|
|
||||||
use crate::components::button::Button;
|
use crate::components::button::Button;
|
||||||
use crate::components::label::Label;
|
use crate::components::label::Label;
|
||||||
@@ -288,7 +288,7 @@ impl Dropdown {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let menu = gpui::div()
|
let menu = gpui::div()
|
||||||
.w_full() // Same width as trigger
|
.w_full() // Same width as trigger
|
||||||
.p_1()
|
.p_1()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(theme.border)
|
.border_color(theme.border)
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use gpui::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::theme::{ActiveTheme, Theme};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ChipVariant {
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Warning,
|
||||||
|
Danger,
|
||||||
|
Muted,
|
||||||
|
Accent,
|
||||||
|
Custom {
|
||||||
|
background: gpui::Rgba,
|
||||||
|
foreground: gpui::Rgba,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChipVariant {
|
||||||
|
fn colors(self, theme: &Theme) -> (gpui::Rgba, gpui::Rgba) {
|
||||||
|
match self {
|
||||||
|
ChipVariant::Info => (Theme::alpha(theme.info, 0.18), theme.info),
|
||||||
|
ChipVariant::Success => (Theme::alpha(theme.success, 0.18), theme.success),
|
||||||
|
ChipVariant::Warning => (Theme::alpha(theme.warning, 0.18), theme.warning),
|
||||||
|
ChipVariant::Danger => (Theme::alpha(theme.error, 0.18), theme.error),
|
||||||
|
ChipVariant::Muted => (Theme::alpha(theme.muted, 0.18), theme.muted),
|
||||||
|
ChipVariant::Accent => (Theme::alpha(theme.accent, 0.15), theme.accent),
|
||||||
|
ChipVariant::Custom {
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
} => (background, foreground),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, IntoElement)]
|
||||||
|
pub struct Chip {
|
||||||
|
label: gpui::SharedString,
|
||||||
|
variant: ChipVariant,
|
||||||
|
selected: bool,
|
||||||
|
on_remove:
|
||||||
|
Option<Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chip {
|
||||||
|
pub fn new(label: impl Into<gpui::SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
variant: ChipVariant::Info,
|
||||||
|
selected: false,
|
||||||
|
on_remove: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn variant(mut self, variant: ChipVariant) -> Self {
|
||||||
|
self.variant = variant;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn custom(self, background: gpui::Rgba, foreground: gpui::Rgba) -> Self {
|
||||||
|
self.variant(ChipVariant::Custom {
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(mut self, selected: bool) -> Self {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn removable(
|
||||||
|
mut self,
|
||||||
|
on_remove: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
self.on_remove = Some(on_remove);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for Chip {
|
||||||
|
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let (bg, fg) = self.variant.colors(theme);
|
||||||
|
|
||||||
|
let mut chip = gpui::div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.px(gpui::rems(0.5))
|
||||||
|
.py(gpui::rems(0.125))
|
||||||
|
.rounded(gpui::rems(0.25))
|
||||||
|
.text_xs()
|
||||||
|
.font_weight(gpui::FontWeight::MEDIUM);
|
||||||
|
|
||||||
|
if self.selected {
|
||||||
|
chip = chip
|
||||||
|
.bg(theme.accent)
|
||||||
|
.text_color(theme.selection_foreground)
|
||||||
|
.border_2()
|
||||||
|
.border_color(theme.focus_ring);
|
||||||
|
} else {
|
||||||
|
chip = chip.bg(bg).text_color(fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
chip = chip.child(self.label);
|
||||||
|
|
||||||
|
if let Some(on_remove) = self.on_remove {
|
||||||
|
chip = chip.child(
|
||||||
|
gpui::div()
|
||||||
|
.text_color(theme.muted)
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|s| s.text_color(theme.error))
|
||||||
|
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
|
||||||
|
(on_remove)(event, window, app);
|
||||||
|
})
|
||||||
|
.child("×"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
chip
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
use gpui::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::components::dialog::{Dialog, DialogButton, DialogButtonVariant};
|
||||||
|
use crate::theme::Theme;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ConfirmDialog {
|
||||||
|
title: String,
|
||||||
|
hint: Option<String>,
|
||||||
|
cancel: Option<(
|
||||||
|
String,
|
||||||
|
Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
)>,
|
||||||
|
confirm: Option<(
|
||||||
|
String,
|
||||||
|
DialogButtonVariant,
|
||||||
|
Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
)>,
|
||||||
|
on_backdrop_click:
|
||||||
|
Option<Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfirmDialog {
|
||||||
|
pub fn new(title: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
title: title.into(),
|
||||||
|
hint: None,
|
||||||
|
cancel: None,
|
||||||
|
confirm: None,
|
||||||
|
on_backdrop_click: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hint(mut self, hint: impl Into<String>) -> Self {
|
||||||
|
self.hint = Some(hint.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel(
|
||||||
|
mut self,
|
||||||
|
label: impl Into<String>,
|
||||||
|
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
self.cancel = Some((label.into(), on_click));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn primary(
|
||||||
|
mut self,
|
||||||
|
label: impl Into<String>,
|
||||||
|
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
self.confirm = Some((label.into(), DialogButtonVariant::Primary, on_click));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn danger(
|
||||||
|
mut self,
|
||||||
|
label: impl Into<String>,
|
||||||
|
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
self.confirm = Some((label.into(), DialogButtonVariant::Danger, on_click));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_backdrop_click(
|
||||||
|
mut self,
|
||||||
|
handler: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
self.on_backdrop_click = Some(handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(self, theme: &Theme) -> gpui::Div {
|
||||||
|
let mut dialog = Dialog::new(self.title);
|
||||||
|
if let Some(hint) = self.hint {
|
||||||
|
dialog = dialog.hint(hint);
|
||||||
|
}
|
||||||
|
if let Some((label, on_click)) = self.cancel {
|
||||||
|
dialog = dialog.button(DialogButton::default(label, on_click));
|
||||||
|
}
|
||||||
|
if let Some((label, variant, on_click)) = self.confirm {
|
||||||
|
let button = match variant {
|
||||||
|
DialogButtonVariant::Primary => DialogButton::primary(label, on_click),
|
||||||
|
DialogButtonVariant::Danger => DialogButton::danger(label, on_click),
|
||||||
|
DialogButtonVariant::Default => DialogButton::default(label, on_click),
|
||||||
|
};
|
||||||
|
dialog = dialog.button(button);
|
||||||
|
}
|
||||||
|
if let Some(handler) = self.on_backdrop_click {
|
||||||
|
dialog = dialog.on_backdrop_click(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.render(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
use crate::components::label::Label;
|
||||||
|
use crate::theme::Theme;
|
||||||
|
use gpui::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct DialogButton {
|
||||||
|
label: String,
|
||||||
|
variant: DialogButtonVariant,
|
||||||
|
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum DialogButtonVariant {
|
||||||
|
Default,
|
||||||
|
Primary,
|
||||||
|
Danger,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DialogButton {
|
||||||
|
pub fn new(
|
||||||
|
label: impl Into<String>,
|
||||||
|
variant: DialogButtonVariant,
|
||||||
|
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
variant,
|
||||||
|
on_click,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default(
|
||||||
|
label: impl Into<String>,
|
||||||
|
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(label, DialogButtonVariant::Default, on_click)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn primary(
|
||||||
|
label: impl Into<String>,
|
||||||
|
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(label, DialogButtonVariant::Primary, on_click)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn danger(
|
||||||
|
label: impl Into<String>,
|
||||||
|
on_click: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new(label, DialogButtonVariant::Danger, on_click)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Dialog {
|
||||||
|
title: String,
|
||||||
|
message: Option<String>,
|
||||||
|
hint: Option<String>,
|
||||||
|
buttons: Vec<DialogButton>,
|
||||||
|
on_backdrop_click:
|
||||||
|
Option<Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dialog {
|
||||||
|
pub fn new(title: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
title: title.into(),
|
||||||
|
message: None,
|
||||||
|
hint: None,
|
||||||
|
buttons: Vec::new(),
|
||||||
|
on_backdrop_click: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message(mut self, message: impl Into<String>) -> Self {
|
||||||
|
self.message = Some(message.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hint(mut self, hint: impl Into<String>) -> Self {
|
||||||
|
self.hint = Some(hint.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn button(mut self, button: DialogButton) -> Self {
|
||||||
|
self.buttons.push(button);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_backdrop_click(
|
||||||
|
mut self,
|
||||||
|
handler: Arc<dyn Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static>,
|
||||||
|
) -> Self {
|
||||||
|
self.on_backdrop_click = Some(handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(self, theme: &Theme) -> gpui::Div {
|
||||||
|
let card = gpui::div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.p(gpui::rems(1.0))
|
||||||
|
.min_w(gpui::rems(20.0))
|
||||||
|
.bg(theme.panel)
|
||||||
|
.border_2()
|
||||||
|
.border_color(theme.error)
|
||||||
|
.rounded_md()
|
||||||
|
.shadow_lg()
|
||||||
|
.child(
|
||||||
|
Label::new(&self.title)
|
||||||
|
.text_color(theme.foreground)
|
||||||
|
.text_sm()
|
||||||
|
.font_weight(gpui::FontWeight::MEDIUM),
|
||||||
|
);
|
||||||
|
|
||||||
|
let card = if let Some(message) = &self.message {
|
||||||
|
card.child(Label::new(message).text_color(theme.foreground).text_sm())
|
||||||
|
} else {
|
||||||
|
card
|
||||||
|
};
|
||||||
|
|
||||||
|
let card = if let Some(hint) = &self.hint {
|
||||||
|
card.child(
|
||||||
|
gpui::div()
|
||||||
|
.flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme.muted)
|
||||||
|
.child(hint.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
card
|
||||||
|
};
|
||||||
|
|
||||||
|
let card = if !self.buttons.is_empty() {
|
||||||
|
let button_row = self.buttons.into_iter().fold(
|
||||||
|
gpui::div().flex().gap_2().justify_end(),
|
||||||
|
|row, button| {
|
||||||
|
let (bg_color, text_color, hover_bg) = match button.variant {
|
||||||
|
DialogButtonVariant::Default => {
|
||||||
|
(theme.raised, theme.foreground, theme.hover)
|
||||||
|
}
|
||||||
|
DialogButtonVariant::Primary => (
|
||||||
|
theme.accent,
|
||||||
|
theme.selection_foreground,
|
||||||
|
Theme::alpha(theme.accent, 0.8),
|
||||||
|
),
|
||||||
|
DialogButtonVariant::Danger => (
|
||||||
|
theme.error,
|
||||||
|
theme.selection_foreground,
|
||||||
|
Theme::alpha(theme.error, 0.8),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let btn = gpui::div()
|
||||||
|
.px(gpui::rems(0.75))
|
||||||
|
.py(gpui::rems(0.35))
|
||||||
|
.rounded_md()
|
||||||
|
.text_sm()
|
||||||
|
.cursor_pointer();
|
||||||
|
|
||||||
|
let btn = match button.variant {
|
||||||
|
DialogButtonVariant::Default => btn
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.divider)
|
||||||
|
.bg(bg_color)
|
||||||
|
.text_color(text_color)
|
||||||
|
.hover(move |s| s.bg(hover_bg)),
|
||||||
|
_ => btn
|
||||||
|
.bg(bg_color)
|
||||||
|
.text_color(text_color)
|
||||||
|
.hover(move |s| s.bg(hover_bg)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let handler = button.on_click.clone();
|
||||||
|
let btn = btn
|
||||||
|
.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
|
||||||
|
(handler)(event, window, app);
|
||||||
|
})
|
||||||
|
.child(Label::new(&button.label));
|
||||||
|
|
||||||
|
row.child(btn)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
card.child(button_row)
|
||||||
|
} else {
|
||||||
|
card
|
||||||
|
};
|
||||||
|
|
||||||
|
let card = card.on_mouse_down(gpui::MouseButton::Left, |_event, _window, _app| {});
|
||||||
|
|
||||||
|
let backdrop = gpui::div()
|
||||||
|
.absolute()
|
||||||
|
.inset_0()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.bg(Theme::alpha(theme.backdrop, 0.7))
|
||||||
|
.child(card);
|
||||||
|
|
||||||
|
if let Some(on_backdrop_click) = self.on_backdrop_click {
|
||||||
|
backdrop.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
|
||||||
|
(on_backdrop_click)(event, window, app);
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
backdrop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
use gpui::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::label::Label;
|
||||||
|
use crate::theme::{ActiveTheme, Theme};
|
||||||
|
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct FieldRow {
|
||||||
|
label: gpui::SharedString,
|
||||||
|
value: gpui::AnyElement,
|
||||||
|
error: Option<gpui::SharedString>,
|
||||||
|
label_width: gpui::Length,
|
||||||
|
style: gpui::StyleRefinement,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldRow {
|
||||||
|
pub fn new(label: impl Into<gpui::SharedString>, value: impl IntoElement) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
value: value.into_any_element(),
|
||||||
|
error: None,
|
||||||
|
label_width: gpui::rems(10.0).into(),
|
||||||
|
style: gpui::StyleRefinement::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(mut self, error: Option<gpui::SharedString>) -> Self {
|
||||||
|
self.error = error;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label_width(mut self, width: gpui::Length) -> Self {
|
||||||
|
self.label_width = width;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for FieldRow {
|
||||||
|
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for FieldRow {
|
||||||
|
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let label_color = Theme::alpha(theme.foreground, 0.72);
|
||||||
|
|
||||||
|
let row = gpui::div()
|
||||||
|
.flex()
|
||||||
|
.items_start()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
Label::new(self.label)
|
||||||
|
.text_color(label_color)
|
||||||
|
.text_sm()
|
||||||
|
.w(self.label_width),
|
||||||
|
)
|
||||||
|
.child(gpui::div().flex_1().min_w_0().child(self.value));
|
||||||
|
|
||||||
|
let mut container = gpui::div().flex().flex_col().gap_1().child(row);
|
||||||
|
if let Some(error) = self.error {
|
||||||
|
container = container.child(
|
||||||
|
gpui::div()
|
||||||
|
.flex()
|
||||||
|
.items_start()
|
||||||
|
.gap_3()
|
||||||
|
.child(gpui::div().w(self.label_width))
|
||||||
|
.child(Label::new(error).text_xs().text_color(theme.error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.style().refine(&self.style);
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct KvRow {
|
||||||
|
label: gpui::SharedString,
|
||||||
|
value: gpui::AnyElement,
|
||||||
|
label_width: gpui::Length,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KvRow {
|
||||||
|
pub fn new(label: impl Into<gpui::SharedString>, value: impl IntoElement) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
value: value.into_any_element(),
|
||||||
|
label_width: gpui::rems(10.0).into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label_width(mut self, width: gpui::Length) -> Self {
|
||||||
|
self.label_width = width;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for KvRow {
|
||||||
|
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||||
|
FieldRow::new(self.label, self.value).label_width(self.label_width)
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-15
@@ -2,7 +2,7 @@ mod suggestion;
|
|||||||
|
|
||||||
use crate::theme::{ActiveTheme, Theme};
|
use crate::theme::{ActiveTheme, Theme};
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{Corner, anchored, deferred, px, point};
|
use gpui::{Corner, anchored, deferred, point, px};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub use suggestion::Suggestion;
|
pub use suggestion::Suggestion;
|
||||||
@@ -348,6 +348,9 @@ impl Input {
|
|||||||
_window: &mut gpui::Window,
|
_window: &mut gpui::Window,
|
||||||
cx: &mut gpui::Context<Self>,
|
cx: &mut gpui::Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
// Note: This handler is only attached when the input has real focus
|
||||||
|
// (see render() method), so we don't need to check is_focused here anymore
|
||||||
|
|
||||||
let key = event.keystroke.key.as_str();
|
let key = event.keystroke.key.as_str();
|
||||||
let ctrl = event.keystroke.modifiers.control;
|
let ctrl = event.keystroke.modifiers.control;
|
||||||
let shift = event.keystroke.modifiers.shift;
|
let shift = event.keystroke.modifiers.shift;
|
||||||
@@ -358,11 +361,13 @@ impl Input {
|
|||||||
|
|
||||||
match key {
|
match key {
|
||||||
"enter" => {
|
"enter" => {
|
||||||
if self.multiline && shift {
|
// For multiline, Enter inserts newline (modal handles exit via commands)
|
||||||
|
if self.multiline {
|
||||||
self.insert_text("\n", cx);
|
self.insert_text("\n", cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For single-line, check suggestions then submit
|
||||||
if self.suggestions_open {
|
if self.suggestions_open {
|
||||||
self.accept_suggestion(cx);
|
self.accept_suggestion(cx);
|
||||||
} else {
|
} else {
|
||||||
@@ -481,7 +486,10 @@ impl Input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_suggestions_external(&self, cx: &gpui::Context<Self>) -> Option<gpui::AnyElement> {
|
pub fn render_suggestions_external(
|
||||||
|
&self,
|
||||||
|
cx: &gpui::Context<Self>,
|
||||||
|
) -> Option<gpui::AnyElement> {
|
||||||
if !self.suggestions_open {
|
if !self.suggestions_open {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -514,15 +522,17 @@ impl Input {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(gpui::div()
|
Some(
|
||||||
.mt_1()
|
gpui::div()
|
||||||
.border_1()
|
.mt_1()
|
||||||
.border_color(theme.border)
|
.border_1()
|
||||||
.bg(theme.panel)
|
.border_color(theme.border)
|
||||||
.rounded_md()
|
.bg(theme.panel)
|
||||||
.overflow_hidden()
|
.rounded_md()
|
||||||
.children(items)
|
.overflow_hidden()
|
||||||
.into_any_element())
|
.children(items)
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_suggestions(&self, cx: &gpui::Context<Self>) -> impl IntoElement {
|
fn render_suggestions(&self, cx: &gpui::Context<Self>) -> impl IntoElement {
|
||||||
@@ -706,11 +716,10 @@ impl gpui::Render for Input {
|
|||||||
|
|
||||||
let focus_handle = self.focus.clone();
|
let focus_handle = self.focus.clone();
|
||||||
|
|
||||||
gpui::div()
|
let base = gpui::div()
|
||||||
.id(self.id.clone())
|
.id(self.id.clone())
|
||||||
.key_context("Input")
|
.key_context("Input")
|
||||||
.track_focus(&self.focus)
|
.track_focus(&self.focus)
|
||||||
.on_key_down(cx.listener(Self::handle_key_down))
|
|
||||||
.on_mouse_down(gpui::MouseButton::Left, move |_ev, window, _cx| {
|
.on_mouse_down(gpui::MouseButton::Left, move |_ev, window, _cx| {
|
||||||
window.focus(&focus_handle);
|
window.focus(&focus_handle);
|
||||||
})
|
})
|
||||||
@@ -728,7 +737,16 @@ impl gpui::Render for Input {
|
|||||||
.p_2()
|
.p_2()
|
||||||
.cursor(gpui::CursorStyle::IBeam)
|
.cursor(gpui::CursorStyle::IBeam)
|
||||||
.child(content)
|
.child(content)
|
||||||
.child(self.render_suggestions(cx))
|
.child(self.render_suggestions(cx));
|
||||||
|
|
||||||
|
// Only attach key handler when this input actually has focus
|
||||||
|
// This prevents the input from intercepting keyboard events
|
||||||
|
// when it only has "visual focus" (focus ring) but not real focus
|
||||||
|
if is_focused {
|
||||||
|
base.on_key_down(cx.listener(Self::handle_key_down))
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
pub mod action_button;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
|
pub mod chip;
|
||||||
|
pub mod confirm_dialog;
|
||||||
|
pub mod dialog;
|
||||||
pub mod divider;
|
pub mod divider;
|
||||||
|
pub mod field_row;
|
||||||
pub mod icon;
|
pub mod icon;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod label;
|
pub mod label;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod panel;
|
pub mod panel;
|
||||||
|
pub mod section_card;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use gpui::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::label::Label;
|
||||||
|
use crate::theme::{ActiveTheme, Theme};
|
||||||
|
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct SectionCard {
|
||||||
|
title: gpui::SharedString,
|
||||||
|
content: Vec<gpui::AnyElement>,
|
||||||
|
style: gpui::StyleRefinement,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectionCard {
|
||||||
|
pub fn new(title: impl Into<gpui::SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
title: title.into(),
|
||||||
|
content: Vec::new(),
|
||||||
|
style: gpui::StyleRefinement::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn child(mut self, child: impl IntoElement) -> Self {
|
||||||
|
self.content.push(child.into_any_element());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn children<E>(mut self, children: impl IntoIterator<Item = E>) -> Self
|
||||||
|
where
|
||||||
|
E: IntoElement,
|
||||||
|
{
|
||||||
|
self.content
|
||||||
|
.extend(children.into_iter().map(|c| c.into_any_element()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for SectionCard {
|
||||||
|
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for SectionCard {
|
||||||
|
fn render(mut self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let title = self.title.to_string().to_uppercase();
|
||||||
|
let section_title_color = Theme::alpha(theme.foreground, 0.88);
|
||||||
|
|
||||||
|
let header = Label::new(title)
|
||||||
|
.text_sm()
|
||||||
|
.text_color(section_title_color)
|
||||||
|
.font_weight(gpui::FontWeight::BOLD);
|
||||||
|
|
||||||
|
let children: Vec<gpui::AnyElement> = self.content.drain(..).collect();
|
||||||
|
|
||||||
|
let mut container = gpui::div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.bg(theme.raised)
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.divider)
|
||||||
|
.rounded_md()
|
||||||
|
.px(gpui::rems(0.75))
|
||||||
|
.py(gpui::rems(0.5))
|
||||||
|
.child(header)
|
||||||
|
.children(children);
|
||||||
|
|
||||||
|
container.style().refine(&self.style);
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-5
@@ -4,12 +4,16 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn close_task_detail(&mut self, cx: &mut gpui::Context<Self>) {
|
fn close_task_detail(
|
||||||
|
&mut self,
|
||||||
|
window: Option<&mut gpui::Window>,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) {
|
||||||
self.task_detail_modal.update(cx, |modal, cx| {
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
if modal.is_editing() {
|
if modal.is_editing() {
|
||||||
modal.cancel_edit(cx);
|
modal.cancel_edit(window, cx);
|
||||||
} else {
|
} else {
|
||||||
modal.close(cx);
|
modal.close(window, cx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,12 +115,26 @@ impl CommandDispatcher for App {
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
Command::OpenTaskEdit => {
|
||||||
|
match self.focus_target {
|
||||||
|
FocusTarget::SidebarProjects | FocusTarget::SidebarTags => {
|
||||||
|
// For sidebar, just open in view mode (or ignore)
|
||||||
|
self.sidebar.update(cx, |sidebar, cx| {
|
||||||
|
sidebar.dispatch(Command::OpenSelectedTask, cx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.open_selected_task_edit(None, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
Command::CloseModal => {
|
Command::CloseModal => {
|
||||||
self.close_task_detail(cx);
|
self.close_task_detail(None, cx);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Command::SaveModal => {
|
Command::SaveModal => {
|
||||||
self.close_task_detail(cx);
|
self.close_task_detail(None, cx);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Command::ModalScrollUp => {
|
Command::ModalScrollUp => {
|
||||||
|
|||||||
+25
-3
@@ -137,6 +137,23 @@ impl App {
|
|||||||
&mut self,
|
&mut self,
|
||||||
window: Option<&mut gpui::Window>,
|
window: Option<&mut gpui::Window>,
|
||||||
cx: &mut gpui::Context<Self>,
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) {
|
||||||
|
self.open_selected_task_mode(false, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn open_selected_task_edit(
|
||||||
|
&mut self,
|
||||||
|
window: Option<&mut gpui::Window>,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) {
|
||||||
|
self.open_selected_task_mode(true, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_selected_task_mode(
|
||||||
|
&mut self,
|
||||||
|
edit_mode: bool,
|
||||||
|
window: Option<&mut gpui::Window>,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
) {
|
) {
|
||||||
if self.task_detail_modal.read(cx).is_open() {
|
if self.task_detail_modal.read(cx).is_open() {
|
||||||
return;
|
return;
|
||||||
@@ -147,12 +164,13 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.open_task_detail(task_id, window, cx);
|
self.open_task_detail(task_id, edit_mode, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn open_task_detail(
|
pub(super) fn open_task_detail(
|
||||||
&mut self,
|
&mut self,
|
||||||
task_id: uuid::Uuid,
|
task_id: uuid::Uuid,
|
||||||
|
edit_mode: bool,
|
||||||
window: Option<&mut gpui::Window>,
|
window: Option<&mut gpui::Window>,
|
||||||
cx: &mut gpui::Context<Self>,
|
cx: &mut gpui::Context<Self>,
|
||||||
) {
|
) {
|
||||||
@@ -162,7 +180,11 @@ impl App {
|
|||||||
match self.task_service.get_task_detail(task_id, &tasks) {
|
match self.task_service.get_task_detail(task_id, &tasks) {
|
||||||
Ok(detail) => {
|
Ok(detail) => {
|
||||||
self.task_detail_modal.update(cx, |modal, cx| {
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
modal.open_with_detail(detail, window, cx);
|
if edit_mode {
|
||||||
|
modal.open_with_detail_edit(detail, window, cx);
|
||||||
|
} else {
|
||||||
|
modal.open_with_detail(detail, window, cx);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -337,7 +359,7 @@ impl App {
|
|||||||
self.apply_task_update(task, cx);
|
self.apply_task_update(task, cx);
|
||||||
} else {
|
} else {
|
||||||
self.task_detail_modal.update(cx, |modal, cx| {
|
self.task_detail_modal.update(cx, |modal, cx| {
|
||||||
modal.cancel_edit(cx);
|
modal.cancel_edit(None, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub enum Command {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
OpenSelectedTask,
|
OpenSelectedTask,
|
||||||
|
OpenTaskEdit,
|
||||||
Sync,
|
Sync,
|
||||||
|
|
||||||
// Focus
|
// Focus
|
||||||
@@ -27,6 +28,21 @@ pub enum Command {
|
|||||||
SaveModal,
|
SaveModal,
|
||||||
ModalScrollUp,
|
ModalScrollUp,
|
||||||
ModalScrollDown,
|
ModalScrollDown,
|
||||||
|
EnterEditMode,
|
||||||
|
ExitEditField,
|
||||||
|
EnterEditField,
|
||||||
|
ModalFocusNext,
|
||||||
|
ModalFocusPrev,
|
||||||
|
SubmitOrExitField,
|
||||||
|
ModalItemPrev,
|
||||||
|
ModalItemNext,
|
||||||
|
DeleteSelectedItem,
|
||||||
|
EditSelectedItem,
|
||||||
|
CopySelectedItem,
|
||||||
|
ConfirmYes,
|
||||||
|
ConfirmNo,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
ApplySearch,
|
ApplySearch,
|
||||||
@@ -62,6 +78,7 @@ impl Command {
|
|||||||
"PrevPage" => Some(Self::PrevPage),
|
"PrevPage" => Some(Self::PrevPage),
|
||||||
"ClearSelection" => Some(Self::ClearSelection),
|
"ClearSelection" => Some(Self::ClearSelection),
|
||||||
"OpenSelectedTask" => Some(Self::OpenSelectedTask),
|
"OpenSelectedTask" => Some(Self::OpenSelectedTask),
|
||||||
|
"OpenTaskEdit" => Some(Self::OpenTaskEdit),
|
||||||
"Sync" => Some(Self::Sync),
|
"Sync" => Some(Self::Sync),
|
||||||
"FocusSearch" => Some(Self::FocusSearch),
|
"FocusSearch" => Some(Self::FocusSearch),
|
||||||
"FocusTable" => Some(Self::FocusTable),
|
"FocusTable" => Some(Self::FocusTable),
|
||||||
@@ -74,6 +91,21 @@ impl Command {
|
|||||||
"SaveModal" => Some(Self::SaveModal),
|
"SaveModal" => Some(Self::SaveModal),
|
||||||
"ModalScrollUp" => Some(Self::ModalScrollUp),
|
"ModalScrollUp" => Some(Self::ModalScrollUp),
|
||||||
"ModalScrollDown" => Some(Self::ModalScrollDown),
|
"ModalScrollDown" => Some(Self::ModalScrollDown),
|
||||||
|
"EnterEditMode" => Some(Self::EnterEditMode),
|
||||||
|
"ExitEditField" => Some(Self::ExitEditField),
|
||||||
|
"EnterEditField" => Some(Self::EnterEditField),
|
||||||
|
"ModalFocusNext" => Some(Self::ModalFocusNext),
|
||||||
|
"ModalFocusPrev" => Some(Self::ModalFocusPrev),
|
||||||
|
"SubmitOrExitField" => Some(Self::SubmitOrExitField),
|
||||||
|
"ModalItemPrev" => Some(Self::ModalItemPrev),
|
||||||
|
"ModalItemNext" => Some(Self::ModalItemNext),
|
||||||
|
"DeleteSelectedItem" => Some(Self::DeleteSelectedItem),
|
||||||
|
"EditSelectedItem" => Some(Self::EditSelectedItem),
|
||||||
|
"CopySelectedItem" => Some(Self::CopySelectedItem),
|
||||||
|
"ConfirmYes" => Some(Self::ConfirmYes),
|
||||||
|
"ConfirmNo" => Some(Self::ConfirmNo),
|
||||||
|
"Undo" => Some(Self::Undo),
|
||||||
|
"Redo" => Some(Self::Redo),
|
||||||
"ApplySearch" => Some(Self::ApplySearch),
|
"ApplySearch" => Some(Self::ApplySearch),
|
||||||
"ClearFilters" => Some(Self::ClearFilters),
|
"ClearFilters" => Some(Self::ClearFilters),
|
||||||
"ClearAllFilters" => Some(Self::ClearAllFilters),
|
"ClearAllFilters" => Some(Self::ClearAllFilters),
|
||||||
@@ -104,6 +136,7 @@ impl Command {
|
|||||||
Self::PrevPage => "PrevPage",
|
Self::PrevPage => "PrevPage",
|
||||||
Self::ClearSelection => "ClearSelection",
|
Self::ClearSelection => "ClearSelection",
|
||||||
Self::OpenSelectedTask => "OpenSelectedTask",
|
Self::OpenSelectedTask => "OpenSelectedTask",
|
||||||
|
Self::OpenTaskEdit => "OpenTaskEdit",
|
||||||
Self::Sync => "Sync",
|
Self::Sync => "Sync",
|
||||||
Self::FocusSearch => "FocusSearch",
|
Self::FocusSearch => "FocusSearch",
|
||||||
Self::FocusTable => "FocusTable",
|
Self::FocusTable => "FocusTable",
|
||||||
@@ -116,6 +149,21 @@ impl Command {
|
|||||||
Self::SaveModal => "SaveModal",
|
Self::SaveModal => "SaveModal",
|
||||||
Self::ModalScrollUp => "ModalScrollUp",
|
Self::ModalScrollUp => "ModalScrollUp",
|
||||||
Self::ModalScrollDown => "ModalScrollDown",
|
Self::ModalScrollDown => "ModalScrollDown",
|
||||||
|
Self::EnterEditMode => "EnterEditMode",
|
||||||
|
Self::ExitEditField => "ExitEditField",
|
||||||
|
Self::EnterEditField => "EnterEditField",
|
||||||
|
Self::ModalFocusNext => "ModalFocusNext",
|
||||||
|
Self::ModalFocusPrev => "ModalFocusPrev",
|
||||||
|
Self::SubmitOrExitField => "SubmitOrExitField",
|
||||||
|
Self::ModalItemPrev => "ModalItemPrev",
|
||||||
|
Self::ModalItemNext => "ModalItemNext",
|
||||||
|
Self::DeleteSelectedItem => "DeleteSelectedItem",
|
||||||
|
Self::EditSelectedItem => "EditSelectedItem",
|
||||||
|
Self::CopySelectedItem => "CopySelectedItem",
|
||||||
|
Self::ConfirmYes => "ConfirmYes",
|
||||||
|
Self::ConfirmNo => "ConfirmNo",
|
||||||
|
Self::Undo => "Undo",
|
||||||
|
Self::Redo => "Redo",
|
||||||
Self::ApplySearch => "ApplySearch",
|
Self::ApplySearch => "ApplySearch",
|
||||||
Self::ClearFilters => "ClearFilters",
|
Self::ClearFilters => "ClearFilters",
|
||||||
Self::ClearAllFilters => "ClearAllFilters",
|
Self::ClearAllFilters => "ClearAllFilters",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub enum ContextId {
|
|||||||
SidebarProjects,
|
SidebarProjects,
|
||||||
SidebarTags,
|
SidebarTags,
|
||||||
Modal,
|
Modal,
|
||||||
|
ModalEditNav,
|
||||||
ModalInput,
|
ModalInput,
|
||||||
ModalDropdown,
|
ModalDropdown,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
@@ -21,6 +22,7 @@ impl ContextId {
|
|||||||
"sidebarprojects" | "SidebarProjects" => Some(Self::SidebarProjects),
|
"sidebarprojects" | "SidebarProjects" => Some(Self::SidebarProjects),
|
||||||
"sidebartags" | "SidebarTags" => Some(Self::SidebarTags),
|
"sidebartags" | "SidebarTags" => Some(Self::SidebarTags),
|
||||||
"modal" | "Modal" => Some(Self::Modal),
|
"modal" | "Modal" => Some(Self::Modal),
|
||||||
|
"modaleditnav" | "ModalEditNav" => Some(Self::ModalEditNav),
|
||||||
"modalinput" | "ModalInput" => Some(Self::ModalInput),
|
"modalinput" | "ModalInput" => Some(Self::ModalInput),
|
||||||
"modaldropdown" | "ModalDropdown" => Some(Self::ModalDropdown),
|
"modaldropdown" | "ModalDropdown" => Some(Self::ModalDropdown),
|
||||||
"filterbar" | "FilterBar" => Some(Self::FilterBar),
|
"filterbar" | "FilterBar" => Some(Self::FilterBar),
|
||||||
@@ -37,6 +39,7 @@ impl ContextId {
|
|||||||
Self::SidebarProjects => "SidebarProjects",
|
Self::SidebarProjects => "SidebarProjects",
|
||||||
Self::SidebarTags => "SidebarTags",
|
Self::SidebarTags => "SidebarTags",
|
||||||
Self::Modal => "Modal",
|
Self::Modal => "Modal",
|
||||||
|
Self::ModalEditNav => "ModalEditNav",
|
||||||
Self::ModalInput => "ModalInput",
|
Self::ModalInput => "ModalInput",
|
||||||
Self::ModalDropdown => "ModalDropdown",
|
Self::ModalDropdown => "ModalDropdown",
|
||||||
Self::FilterBar => "FilterBar",
|
Self::FilterBar => "FilterBar",
|
||||||
|
|||||||
+117
-21
@@ -131,6 +131,11 @@ pub fn build_default_keymap() -> KeymapLayer {
|
|||||||
KeyChord::new(Key::Enter, Mods::none()),
|
KeyChord::new(Key::Enter, Mods::none()),
|
||||||
Command::OpenSelectedTask,
|
Command::OpenSelectedTask,
|
||||||
);
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::Table,
|
||||||
|
KeyChord::new(Key::Char('e'), Mods::none()),
|
||||||
|
Command::OpenTaskEdit,
|
||||||
|
);
|
||||||
layer.bind(
|
layer.bind(
|
||||||
ContextId::Table,
|
ContextId::Table,
|
||||||
KeyChord::new(Key::ArrowLeft, Mods::none()),
|
KeyChord::new(Key::ArrowLeft, Mods::none()),
|
||||||
@@ -397,7 +402,7 @@ pub fn build_default_keymap() -> KeymapLayer {
|
|||||||
Command::BlurInput,
|
Command::BlurInput,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Modal
|
// Modal (View mode)
|
||||||
layer.bind(
|
layer.bind(
|
||||||
ContextId::Modal,
|
ContextId::Modal,
|
||||||
KeyChord::new(Key::Escape, Mods::none()),
|
KeyChord::new(Key::Escape, Mods::none()),
|
||||||
@@ -428,36 +433,137 @@ pub fn build_default_keymap() -> KeymapLayer {
|
|||||||
KeyChord::new(Key::Enter, Mods::ctrl()),
|
KeyChord::new(Key::Enter, Mods::ctrl()),
|
||||||
Command::SaveModal,
|
Command::SaveModal,
|
||||||
);
|
);
|
||||||
|
// Enter edit mode with 'e'
|
||||||
layer.bind(
|
layer.bind(
|
||||||
ContextId::ModalInput,
|
ContextId::Modal,
|
||||||
|
KeyChord::new(Key::Char('e'), Mods::none()),
|
||||||
|
Command::EnterEditMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ModalEditNav - Edit mode, navigating between fields (not typing)
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
KeyChord::new(Key::Escape, Mods::none()),
|
KeyChord::new(Key::Escape, Mods::none()),
|
||||||
Command::CloseModal,
|
Command::CloseModal,
|
||||||
);
|
);
|
||||||
layer.bind(
|
layer.bind(
|
||||||
ContextId::ModalInput,
|
ContextId::ModalEditNav,
|
||||||
KeyChord::new(Key::Enter, Mods::ctrl()),
|
KeyChord::new(Key::Char('s'), Mods::ctrl()),
|
||||||
Command::SaveModal,
|
Command::SaveModal,
|
||||||
);
|
);
|
||||||
layer.bind(
|
layer.bind(
|
||||||
ContextId::ModalInput,
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Char('j'), Mods::none()),
|
||||||
|
Command::ModalFocusNext,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Char('k'), Mods::none()),
|
||||||
|
Command::ModalFocusPrev,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::ArrowDown, Mods::none()),
|
||||||
|
Command::ModalFocusNext,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::ArrowUp, Mods::none()),
|
||||||
|
Command::ModalFocusPrev,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
KeyChord::new(Key::Tab, Mods::none()),
|
KeyChord::new(Key::Tab, Mods::none()),
|
||||||
Command::FocusFilterNext,
|
Command::ModalFocusNext,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Tab, Mods::shift()),
|
||||||
|
Command::ModalFocusPrev,
|
||||||
|
);
|
||||||
|
// Enter/Space - Edit selected item (if any), or enter field (fallback)
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Enter, Mods::none()),
|
||||||
|
Command::EditSelectedItem,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Space, Mods::none()),
|
||||||
|
Command::EditSelectedItem,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Char('u'), Mods::none()),
|
||||||
|
Command::Undo,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Char('r'), Mods::none()),
|
||||||
|
Command::Redo,
|
||||||
|
);
|
||||||
|
// h/l - Navigate items within tags
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Char('h'), Mods::none()),
|
||||||
|
Command::ModalItemPrev,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Char('l'), Mods::none()),
|
||||||
|
Command::ModalItemNext,
|
||||||
|
);
|
||||||
|
// Delete/Backspace - Delete selected item
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Delete, Mods::none()),
|
||||||
|
Command::DeleteSelectedItem,
|
||||||
|
);
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Backspace, Mods::none()),
|
||||||
|
Command::DeleteSelectedItem,
|
||||||
|
);
|
||||||
|
// y - Copy selected annotation OR confirm in delete dialog
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Char('y'), Mods::none()),
|
||||||
|
Command::ConfirmYes,
|
||||||
|
);
|
||||||
|
// n - Cancel delete dialog
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalEditNav,
|
||||||
|
KeyChord::new(Key::Char('n'), Mods::none()),
|
||||||
|
Command::ConfirmNo,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ModalInput - Edit mode, actively typing in an input
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalInput,
|
||||||
|
KeyChord::new(Key::Escape, Mods::none()),
|
||||||
|
Command::ExitEditField,
|
||||||
);
|
);
|
||||||
layer.bind(
|
layer.bind(
|
||||||
ContextId::ModalInput,
|
ContextId::ModalInput,
|
||||||
KeyChord::new(Key::Tab, Mods::shift()),
|
KeyChord::new(Key::Char('s'), Mods::ctrl()),
|
||||||
Command::FocusFilterPrev,
|
Command::SaveModal,
|
||||||
|
);
|
||||||
|
// Enter submits or exits field depending on field type
|
||||||
|
layer.bind(
|
||||||
|
ContextId::ModalInput,
|
||||||
|
KeyChord::new(Key::Enter, Mods::none()),
|
||||||
|
Command::SubmitOrExitField,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ModalDropdown - Edit mode, dropdown is open
|
||||||
layer.bind(
|
layer.bind(
|
||||||
ContextId::ModalDropdown,
|
ContextId::ModalDropdown,
|
||||||
KeyChord::new(Key::Escape, Mods::none()),
|
KeyChord::new(Key::Escape, Mods::none()),
|
||||||
Command::CloseModal,
|
Command::ExitEditField,
|
||||||
);
|
);
|
||||||
layer.bind(
|
layer.bind(
|
||||||
ContextId::ModalDropdown,
|
ContextId::ModalDropdown,
|
||||||
KeyChord::new(Key::Enter, Mods::ctrl()),
|
KeyChord::new(Key::Char('s'), Mods::ctrl()),
|
||||||
Command::SaveModal,
|
Command::SaveModal,
|
||||||
);
|
);
|
||||||
layer.bind(
|
layer.bind(
|
||||||
@@ -490,16 +596,6 @@ pub fn build_default_keymap() -> KeymapLayer {
|
|||||||
KeyChord::new(Key::ArrowUp, Mods::none()),
|
KeyChord::new(Key::ArrowUp, Mods::none()),
|
||||||
Command::SelectPrevOption,
|
Command::SelectPrevOption,
|
||||||
);
|
);
|
||||||
layer.bind(
|
|
||||||
ContextId::ModalDropdown,
|
|
||||||
KeyChord::new(Key::Tab, Mods::none()),
|
|
||||||
Command::FocusFilterNext,
|
|
||||||
);
|
|
||||||
layer.bind(
|
|
||||||
ContextId::ModalDropdown,
|
|
||||||
KeyChord::new(Key::Tab, Mods::shift()),
|
|
||||||
Command::FocusFilterPrev,
|
|
||||||
);
|
|
||||||
|
|
||||||
layer
|
layer
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use gpui::SharedString;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
use crate::task::TaskDetailVm;
|
||||||
|
|
||||||
|
pub(super) type AnnotationId = u64;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) struct AnnotationState {
|
||||||
|
pub(super) items: Vec<AnnotationView>,
|
||||||
|
pub(super) draft: SharedString,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AnnotationState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
items: Vec::new(),
|
||||||
|
draft: SharedString::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) struct AnnotationView {
|
||||||
|
pub(super) id: AnnotationId,
|
||||||
|
pub(super) created_at: DateTime<Utc>,
|
||||||
|
pub(super) text: SharedString,
|
||||||
|
pub(super) origin: AnnotationOrigin,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum AnnotationOrigin {
|
||||||
|
Original,
|
||||||
|
Added,
|
||||||
|
Deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn annotation_id(entry: DateTime<Utc>, text: &str, index: usize) -> AnnotationId {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
entry.timestamp_millis().hash(&mut hasher);
|
||||||
|
text.hash(&mut hasher);
|
||||||
|
index.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnnotationState {
|
||||||
|
pub(super) fn from_detail(detail: &TaskDetailVm) -> Self {
|
||||||
|
let items: Vec<AnnotationView> = detail
|
||||||
|
.annotations
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, annotation)| AnnotationView {
|
||||||
|
id: annotation_id(annotation.entry, &annotation.content, index),
|
||||||
|
created_at: annotation.entry,
|
||||||
|
text: annotation.content.clone().into(),
|
||||||
|
origin: AnnotationOrigin::Original,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
items,
|
||||||
|
draft: SharedString::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn set_draft(&mut self, value: &str) {
|
||||||
|
self.draft = value.to_string().into();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn clear_draft(&mut self) {
|
||||||
|
self.draft = SharedString::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn add_local(&mut self, text: SharedString, created_at: DateTime<Utc>) {
|
||||||
|
let id = annotation_id(created_at, text.as_ref(), self.items.len());
|
||||||
|
self.items.push(AnnotationView {
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
text,
|
||||||
|
origin: AnnotationOrigin::Added,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn mark_deleted(&mut self, id: AnnotationId) -> Option<DateTime<Utc>> {
|
||||||
|
let index = self.items.iter().position(|item| item.id == id)?;
|
||||||
|
let item = &mut self.items[index];
|
||||||
|
if item.origin == AnnotationOrigin::Added {
|
||||||
|
self.items.remove(index);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if item.origin == AnnotationOrigin::Deleted {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
item.origin = AnnotationOrigin::Deleted;
|
||||||
|
Some(item.created_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use super::state::ModalFocus;
|
||||||
|
|
||||||
|
pub(super) struct FocusMap;
|
||||||
|
|
||||||
|
impl FocusMap {
|
||||||
|
pub(super) fn section_index(focus: ModalFocus) -> usize {
|
||||||
|
match focus {
|
||||||
|
ModalFocus::None
|
||||||
|
| ModalFocus::StatusDropdown
|
||||||
|
| ModalFocus::Description
|
||||||
|
| ModalFocus::Project
|
||||||
|
| ModalFocus::PriorityDropdown
|
||||||
|
| ModalFocus::Due => 0,
|
||||||
|
ModalFocus::TagsInput => 1,
|
||||||
|
ModalFocus::AnnotationsInput => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn wants_input_focus(focus: ModalFocus) -> bool {
|
||||||
|
matches!(
|
||||||
|
focus,
|
||||||
|
ModalFocus::Description
|
||||||
|
| ModalFocus::Project
|
||||||
|
| ModalFocus::Due
|
||||||
|
| ModalFocus::TagsInput
|
||||||
|
| ModalFocus::AnnotationsInput
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
|
||||||
|
use gpui::SharedString;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::task::{self, TaskDetailVm};
|
||||||
|
use crate::ui::DATE_FORMAT;
|
||||||
|
|
||||||
|
use super::annotations::{AnnotationOrigin, AnnotationState};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub(super) enum FieldId {
|
||||||
|
Description,
|
||||||
|
Due,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(super) struct TaskForm {
|
||||||
|
pub(super) description: String,
|
||||||
|
pub(super) project: String,
|
||||||
|
pub(super) priority: task::TaskPriority,
|
||||||
|
pub(super) status: task::TaskStatus,
|
||||||
|
pub(super) due: String,
|
||||||
|
pub(super) tags: Vec<String>,
|
||||||
|
pub(super) tag_draft: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskForm {
|
||||||
|
pub(super) fn from_detail(detail: &TaskDetailVm) -> Self {
|
||||||
|
let project = detail.overview.project.clone().unwrap_or_default();
|
||||||
|
let due = detail
|
||||||
|
.dates
|
||||||
|
.due
|
||||||
|
.map(|date| date.format(DATE_FORMAT).to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
description: detail.overview.description.clone(),
|
||||||
|
project,
|
||||||
|
priority: detail.overview.priority,
|
||||||
|
status: detail.overview.status.clone(),
|
||||||
|
due,
|
||||||
|
tags: detail.tags.tags.clone(),
|
||||||
|
tag_draft: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_dirty(&self, original: &TaskDetailVm) -> bool {
|
||||||
|
let original_due = original
|
||||||
|
.dates
|
||||||
|
.due
|
||||||
|
.map(|date| date.format(DATE_FORMAT).to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let original_project = original.overview.project.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
if self.description != original.overview.description {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.project.trim() != original_project.trim() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.priority != original.overview.priority {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.status != original.overview.status {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.due.trim() != original_due.trim() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let original_tags: HashSet<String> = original.tags.tags.iter().cloned().collect();
|
||||||
|
let draft_tags: HashSet<String> = self.tags.iter().cloned().collect();
|
||||||
|
draft_tags != original_tags
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_field(&self, field: FieldId) -> Option<SharedString> {
|
||||||
|
match field {
|
||||||
|
FieldId::Description => {
|
||||||
|
if self.description.trim().is_empty() {
|
||||||
|
Some("Description is required".into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldId::Due => {
|
||||||
|
if self.due.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else if NaiveDate::parse_from_str(self.due.trim(), DATE_FORMAT).is_err() {
|
||||||
|
Some("Use YYYY-MM-DD".into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate(&self) -> HashMap<FieldId, SharedString> {
|
||||||
|
let mut errors = HashMap::new();
|
||||||
|
|
||||||
|
for field in [FieldId::Description, FieldId::Due] {
|
||||||
|
if let Some(message) = self.validate_field(field) {
|
||||||
|
errors.insert(field, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn add_tags(&mut self, raw: &str) -> bool {
|
||||||
|
let mut added = false;
|
||||||
|
|
||||||
|
for tag in raw.split(|ch: char| ch.is_whitespace() || ch == ',') {
|
||||||
|
let tag = tag.trim();
|
||||||
|
if tag.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.tags.iter().any(|t| t == tag) {
|
||||||
|
self.tags.push(tag.to_string());
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if added {
|
||||||
|
self.tags
|
||||||
|
.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
added
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove_tag(&mut self, tag: &str) -> bool {
|
||||||
|
let before = self.tags.len();
|
||||||
|
self.tags.retain(|t| t != tag);
|
||||||
|
before != self.tags.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct TaskEditUpdate {
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub project: Option<Option<String>>,
|
||||||
|
pub priority: Option<String>,
|
||||||
|
pub status: Option<task::TaskStatus>,
|
||||||
|
pub due: Option<Option<DateTime<Utc>>>,
|
||||||
|
pub tags: Option<HashSet<String>>,
|
||||||
|
pub annotations_add: Vec<String>,
|
||||||
|
pub annotations_delete: Vec<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskEditUpdate {
|
||||||
|
pub(super) fn is_empty(&self) -> bool {
|
||||||
|
self.description.is_none()
|
||||||
|
&& self.project.is_none()
|
||||||
|
&& self.priority.is_none()
|
||||||
|
&& self.status.is_none()
|
||||||
|
&& self.due.is_none()
|
||||||
|
&& self.tags.is_none()
|
||||||
|
&& self.annotations_add.is_empty()
|
||||||
|
&& self.annotations_delete.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_task_update(
|
||||||
|
original: &TaskDetailVm,
|
||||||
|
draft: &TaskForm,
|
||||||
|
annotations: &AnnotationState,
|
||||||
|
) -> TaskEditUpdate {
|
||||||
|
let mut update = TaskEditUpdate::default();
|
||||||
|
|
||||||
|
if draft.description != original.overview.description {
|
||||||
|
update.description = Some(draft.description.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let draft_project = draft.project.trim();
|
||||||
|
let original_project = original.overview.project.as_deref().unwrap_or("").trim();
|
||||||
|
if draft_project != original_project {
|
||||||
|
update.project = Some(if draft_project.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(draft_project.to_string())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if draft.priority != original.overview.priority {
|
||||||
|
let priority: String = draft.priority.into();
|
||||||
|
update.priority = Some(priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if draft.status != original.overview.status {
|
||||||
|
update.status = Some(draft.status.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let draft_due = draft.due.trim();
|
||||||
|
let original_due = original
|
||||||
|
.dates
|
||||||
|
.due
|
||||||
|
.map(|date| date.format(DATE_FORMAT).to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if draft_due != original_due.trim() {
|
||||||
|
if draft_due.is_empty() {
|
||||||
|
update.due = Some(None);
|
||||||
|
} else if let Ok(date) = NaiveDate::parse_from_str(draft_due, DATE_FORMAT) {
|
||||||
|
let due = Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap());
|
||||||
|
update.due = Some(Some(due));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let draft_tags: HashSet<String> = draft.tags.iter().cloned().collect();
|
||||||
|
let original_tags: HashSet<String> = original.tags.tags.iter().cloned().collect();
|
||||||
|
if draft_tags != original_tags {
|
||||||
|
update.tags = Some(draft_tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
for annotation in &annotations.items {
|
||||||
|
match annotation.origin {
|
||||||
|
AnnotationOrigin::Added => {
|
||||||
|
update.annotations_add.push(annotation.text.to_string());
|
||||||
|
}
|
||||||
|
AnnotationOrigin::Deleted => {
|
||||||
|
update.annotations_delete.push(annotation.created_at);
|
||||||
|
}
|
||||||
|
AnnotationOrigin::Original => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::annotations::AnnotationState;
|
||||||
|
use super::*;
|
||||||
|
use crate::task::model::{
|
||||||
|
TaskAnnotation, TaskDatesVm, TaskDependenciesVm, TaskDetailVm, TaskIdentityVm,
|
||||||
|
TaskMetricsVm, TaskOverviewVm, TaskTagsVm,
|
||||||
|
};
|
||||||
|
use crate::task::{TaskPriority, TaskStatus};
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
|
||||||
|
fn base_detail() -> TaskDetailVm {
|
||||||
|
TaskDetailVm {
|
||||||
|
identity: TaskIdentityVm {
|
||||||
|
uuid: uuid::Uuid::nil(),
|
||||||
|
id: None,
|
||||||
|
working_id: None,
|
||||||
|
},
|
||||||
|
overview: TaskOverviewVm {
|
||||||
|
description: "Test task".to_string(),
|
||||||
|
status: TaskStatus::Pending,
|
||||||
|
project: Some("proj".to_string()),
|
||||||
|
priority: TaskPriority::None,
|
||||||
|
is_active: false,
|
||||||
|
},
|
||||||
|
dates: TaskDatesVm {
|
||||||
|
entry: None,
|
||||||
|
modified: None,
|
||||||
|
start: None,
|
||||||
|
end: None,
|
||||||
|
due: None,
|
||||||
|
scheduled: None,
|
||||||
|
wait: None,
|
||||||
|
until: None,
|
||||||
|
},
|
||||||
|
tags: TaskTagsVm {
|
||||||
|
tags: vec!["a".to_string(), "b".to_string()],
|
||||||
|
virtual_tags: Vec::new(),
|
||||||
|
},
|
||||||
|
dependencies: TaskDependenciesVm {
|
||||||
|
depends_on: Vec::new(),
|
||||||
|
blocked_by: Vec::new(),
|
||||||
|
blocking: Vec::new(),
|
||||||
|
},
|
||||||
|
annotations: Vec::new(),
|
||||||
|
udas: Vec::new(),
|
||||||
|
metrics: TaskMetricsVm::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_update_project_cleared() {
|
||||||
|
let detail = base_detail();
|
||||||
|
let mut draft = TaskForm::from_detail(&detail);
|
||||||
|
draft.project = " ".to_string();
|
||||||
|
|
||||||
|
let annotations = AnnotationState::default();
|
||||||
|
let update = build_task_update(&detail, &draft, &annotations);
|
||||||
|
|
||||||
|
assert_eq!(update.project, Some(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_update_due_invalid_is_ignored() {
|
||||||
|
let detail = base_detail();
|
||||||
|
let mut draft = TaskForm::from_detail(&detail);
|
||||||
|
draft.due = "2023-99-99".to_string();
|
||||||
|
|
||||||
|
let annotations = AnnotationState::default();
|
||||||
|
let update = build_task_update(&detail, &draft, &annotations);
|
||||||
|
|
||||||
|
assert!(update.due.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_update_tags_ignore_order() {
|
||||||
|
let detail = base_detail();
|
||||||
|
let mut draft = TaskForm::from_detail(&detail);
|
||||||
|
draft.tags = vec!["b".to_string(), "a".to_string()];
|
||||||
|
|
||||||
|
let annotations = AnnotationState::default();
|
||||||
|
let update = build_task_update(&detail, &draft, &annotations);
|
||||||
|
|
||||||
|
assert!(update.tags.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_update_annotations_added_deleted() {
|
||||||
|
let mut detail = base_detail();
|
||||||
|
let deleted_at = Utc.with_ymd_and_hms(2023, 5, 2, 12, 0, 0).unwrap();
|
||||||
|
detail.annotations = vec![TaskAnnotation {
|
||||||
|
entry: deleted_at,
|
||||||
|
content: "Original".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let mut annotations = AnnotationState::from_detail(&detail);
|
||||||
|
let original_id = annotations.items[0].id;
|
||||||
|
annotations.mark_deleted(original_id);
|
||||||
|
annotations.add_local("Added".into(), Utc::now());
|
||||||
|
|
||||||
|
let draft = TaskForm::from_detail(&detail);
|
||||||
|
let update = build_task_update(&detail, &draft, &annotations);
|
||||||
|
|
||||||
|
assert_eq!(update.annotations_add, vec!["Added".to_string()]);
|
||||||
|
assert_eq!(update.annotations_delete, vec![deleted_at]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use super::form::TaskForm;
|
||||||
|
|
||||||
|
/// History stack for undo/redo functionality
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) struct FormHistory {
|
||||||
|
states: Vec<TaskForm>,
|
||||||
|
current_index: usize,
|
||||||
|
max_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FormHistory {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
states: Vec::new(),
|
||||||
|
current_index: 0,
|
||||||
|
max_size: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormHistory {
|
||||||
|
pub(super) fn clear(&mut self) {
|
||||||
|
self.states.clear();
|
||||||
|
self.current_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn push(&mut self, state: TaskForm) {
|
||||||
|
if self.current_index < self.states.len() {
|
||||||
|
self.states.truncate(self.current_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.states.push(state);
|
||||||
|
|
||||||
|
if self.states.len() > self.max_size {
|
||||||
|
self.states.remove(0);
|
||||||
|
} else {
|
||||||
|
self.current_index = self.states.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn undo(&mut self) -> Option<&TaskForm> {
|
||||||
|
if self.current_index > 1 {
|
||||||
|
self.current_index -= 1;
|
||||||
|
self.states.get(self.current_index - 1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn redo(&mut self) -> Option<&TaskForm> {
|
||||||
|
if self.current_index < self.states.len() {
|
||||||
|
self.current_index += 1;
|
||||||
|
self.states.get(self.current_index - 1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn can_undo(&self) -> bool {
|
||||||
|
self.current_index > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn can_redo(&self) -> bool {
|
||||||
|
self.current_index < self.states.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
use gpui::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::button::Dropdown;
|
||||||
|
use crate::components::input::Input;
|
||||||
|
use crate::components::modal::ModalFrame;
|
||||||
|
use crate::theme::ActiveTheme;
|
||||||
|
|
||||||
|
use super::TaskDetailModal;
|
||||||
|
use super::state::TaskModalState;
|
||||||
|
|
||||||
|
mod panel;
|
||||||
|
|
||||||
|
pub(super) fn render_task_detail_modal(
|
||||||
|
state: &TaskModalState,
|
||||||
|
annotation_input: &gpui::Entity<Input>,
|
||||||
|
description_input: &gpui::Entity<Input>,
|
||||||
|
project_input: &gpui::Entity<Input>,
|
||||||
|
due_input: &gpui::Entity<Input>,
|
||||||
|
tags_input: &gpui::Entity<Input>,
|
||||||
|
status_dropdown: &gpui::Entity<Dropdown>,
|
||||||
|
priority_dropdown: &gpui::Entity<Dropdown>,
|
||||||
|
focus_handle: &gpui::FocusHandle,
|
||||||
|
form_focus_handle: &gpui::FocusHandle,
|
||||||
|
scroll_handle: &gpui::ScrollHandle,
|
||||||
|
cx: &mut gpui::Context<TaskDetailModal>,
|
||||||
|
on_close_out: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
|
||||||
|
on_close_click: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
|
||||||
|
) -> gpui::AnyElement {
|
||||||
|
let theme = cx.theme().clone();
|
||||||
|
let panel = if let Some(message) = state.error.as_ref() {
|
||||||
|
panel::render_task_detail_placeholder_panel(
|
||||||
|
"Task Details",
|
||||||
|
message.as_ref(),
|
||||||
|
&theme,
|
||||||
|
on_close_click,
|
||||||
|
)
|
||||||
|
} else if state.loading || state.original.is_none() {
|
||||||
|
panel::render_task_detail_placeholder_panel(
|
||||||
|
"Task Details",
|
||||||
|
"Loading task...",
|
||||||
|
&theme,
|
||||||
|
on_close_click,
|
||||||
|
)
|
||||||
|
} else if let Some(detail) = state.original.as_ref() {
|
||||||
|
panel::render_task_detail_panel(
|
||||||
|
detail,
|
||||||
|
state.mode,
|
||||||
|
state.edit_state,
|
||||||
|
&state.form,
|
||||||
|
&state.errors,
|
||||||
|
&state.annotations,
|
||||||
|
state.modal_focus,
|
||||||
|
state.tag_selected,
|
||||||
|
state.annotation_selected,
|
||||||
|
&state.pending_confirm,
|
||||||
|
annotation_input,
|
||||||
|
description_input,
|
||||||
|
project_input,
|
||||||
|
due_input,
|
||||||
|
tags_input,
|
||||||
|
status_dropdown,
|
||||||
|
priority_dropdown,
|
||||||
|
form_focus_handle,
|
||||||
|
scroll_handle,
|
||||||
|
&theme,
|
||||||
|
cx,
|
||||||
|
on_close_click,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
panel::render_task_detail_placeholder_panel(
|
||||||
|
"Task Details",
|
||||||
|
"Loading task...",
|
||||||
|
&theme,
|
||||||
|
on_close_click,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
ModalFrame::new("task-detail-modal", focus_handle.clone(), theme.backdrop)
|
||||||
|
.panel(panel)
|
||||||
|
.on_close(on_close_out)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use gpui::SharedString;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::task::TaskDetailVm;
|
||||||
|
|
||||||
|
use super::annotations::AnnotationState;
|
||||||
|
use super::form::{FieldId, TaskForm};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum ModalMode {
|
||||||
|
View,
|
||||||
|
Edit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModalMode {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::View
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State within Edit mode - determines how keyboard input is handled
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum EditState {
|
||||||
|
/// Navigating between fields with j/k, not typing
|
||||||
|
#[default]
|
||||||
|
Navigating,
|
||||||
|
/// Actively typing in an input field
|
||||||
|
Editing,
|
||||||
|
/// A dropdown menu is open
|
||||||
|
DropdownOpen,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ModalFocus {
|
||||||
|
None,
|
||||||
|
Description,
|
||||||
|
Project,
|
||||||
|
StatusDropdown,
|
||||||
|
PriorityDropdown,
|
||||||
|
Due,
|
||||||
|
TagsInput,
|
||||||
|
AnnotationsInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModalFocus {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::StatusDropdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum InlineEditTarget {
|
||||||
|
Tag(usize),
|
||||||
|
Annotation(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(super) enum ConfirmAction {
|
||||||
|
DeleteTag { index: usize, text: String },
|
||||||
|
DeleteAnnotation { index: usize, text: String },
|
||||||
|
DiscardUnsavedChanges,
|
||||||
|
DiscardUnsavedChangesAndClose,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) struct TaskModalState {
|
||||||
|
pub(super) open: bool,
|
||||||
|
pub(super) task_id: Option<Uuid>,
|
||||||
|
pub(super) loading: bool,
|
||||||
|
pub(super) original: Option<TaskDetailVm>,
|
||||||
|
pub(super) form: TaskForm,
|
||||||
|
pub(super) errors: HashMap<FieldId, SharedString>,
|
||||||
|
pub(super) mode: ModalMode,
|
||||||
|
pub(super) edit_state: EditState,
|
||||||
|
pub(super) annotations: AnnotationState,
|
||||||
|
pub(super) error: Option<SharedString>,
|
||||||
|
pub(super) modal_focus: ModalFocus,
|
||||||
|
|
||||||
|
/// Which tag is selected for h/l navigation (None = input focused)
|
||||||
|
pub(super) tag_selected: Option<usize>,
|
||||||
|
/// Which annotation is selected for h/l navigation (None = input focused)
|
||||||
|
pub(super) annotation_selected: Option<usize>,
|
||||||
|
/// Currently editing an existing item (not creating new)
|
||||||
|
pub(super) inline_edit: Option<InlineEditTarget>,
|
||||||
|
/// Pending confirmation action
|
||||||
|
pub(super) pending_confirm: Option<ConfirmAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TaskModalState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
open: false,
|
||||||
|
task_id: None,
|
||||||
|
loading: false,
|
||||||
|
original: None,
|
||||||
|
form: TaskForm::default(),
|
||||||
|
errors: HashMap::new(),
|
||||||
|
mode: ModalMode::default(),
|
||||||
|
edit_state: EditState::default(),
|
||||||
|
annotations: AnnotationState::default(),
|
||||||
|
error: None,
|
||||||
|
modal_focus: ModalFocus::default(),
|
||||||
|
tag_selected: None,
|
||||||
|
annotation_selected: None,
|
||||||
|
inline_edit: None,
|
||||||
|
pending_confirm: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user