diff --git a/src/components/input/mod.rs b/src/components/input/mod.rs index 28e9456..88b4023 100644 --- a/src/components/input/mod.rs +++ b/src/components/input/mod.rs @@ -14,6 +14,7 @@ pub struct Input { placeholder: gpui::SharedString, cursor_pos: usize, + selection_anchor: Option, multiline: bool, suggestions: Vec, @@ -39,6 +40,7 @@ impl Input { placeholder: placeholder.into(), cursor_pos: 0, + selection_anchor: None, multiline: false, suggestions: vec![], @@ -101,6 +103,11 @@ impl Input { self.suggestions_open } + pub fn close_suggestions(&mut self, cx: &mut gpui::Context) { + self.suggestions_open = false; + cx.notify(); + } + pub fn set_value(&mut self, value: impl Into, cx: &mut gpui::Context) { self.value = value.into(); self.cursor_pos = self.value.len(); @@ -130,6 +137,67 @@ impl Input { cx.notify(); } + fn has_selection(&self) -> bool { + self.selection_anchor.is_some() + } + + fn selection_range(&self) -> Option> { + self.selection_anchor.map(|anchor| { + let start = anchor.min(self.cursor_pos); + let end = anchor.max(self.cursor_pos); + start..end + }) + } + + fn clear_selection(&mut self) { + self.selection_anchor = None; + } + + fn set_selection(&mut self, anchor: usize, cursor: usize) { + self.selection_anchor = Some(anchor); + self.cursor_pos = cursor; + } + + fn select_all(&mut self) { + self.selection_anchor = Some(0); + self.cursor_pos = self.value.len(); + } + + fn delete_selection(&mut self, cx: &mut gpui::Context) -> bool { + if let Some(range) = self.selection_range() { + self.value.drain(range.clone()); + self.cursor_pos = range.start; + self.clear_selection(); + if let Some(on_change) = self.on_change.clone() { + on_change(&self.value, cx); + } + self.refresh_suggestions(cx); + cx.notify(); + return true; + } + false + } + + fn replace_selection(&mut self, text: &str, cx: &mut gpui::Context) { + let range = self.selection_range(); + let insert_pos = range.as_ref().map(|r| r.start).unwrap_or(self.cursor_pos); + let delete_range = range.unwrap_or(self.cursor_pos..self.cursor_pos); + + self.value.replace_range(delete_range.clone(), text); + self.cursor_pos = insert_pos + text.len(); + self.clear_selection(); + + if let Some(on_change) = self.on_change.clone() { + on_change(&self.value, cx); + } + self.refresh_suggestions(cx); + cx.notify(); + } + + fn selected_text(&self) -> Option<&str> { + self.selection_range().map(|range| &self.value[range]) + } + fn word_start_before(&self, pos: usize) -> usize { if pos == 0 { return 0; @@ -167,26 +235,46 @@ impl Input { i } - fn move_left(&mut self) { + fn move_left(&mut self, cx: &mut gpui::Context, clear_selection: bool) { if self.cursor_pos > 0 { let mut new_pos = self.cursor_pos - 1; while new_pos > 0 && !self.value.is_char_boundary(new_pos) { new_pos -= 1; } self.cursor_pos = new_pos; + if clear_selection { + self.clear_selection(); + } + cx.notify(); } } - fn move_right(&mut self) { + fn move_right(&mut self, cx: &mut gpui::Context, clear_selection: bool) { if self.cursor_pos < self.value.len() { let mut new_pos = self.cursor_pos + 1; while new_pos < self.value.len() && !self.value.is_char_boundary(new_pos) { new_pos += 1; } self.cursor_pos = new_pos; + if clear_selection { + self.clear_selection(); + } + cx.notify(); } } + fn move_word_left(&mut self, cx: &mut gpui::Context) { + self.cursor_pos = self.word_start_before(self.cursor_pos); + self.clear_selection(); + cx.notify(); + } + + fn move_word_right(&mut self, cx: &mut gpui::Context) { + self.cursor_pos = self.word_end_after(self.cursor_pos); + self.clear_selection(); + cx.notify(); + } + fn refresh_suggestions(&mut self, cx: &mut gpui::Context) { if let Some(suggest) = &self.suggest { self.suggestions = suggest(&self.value); @@ -271,23 +359,20 @@ impl Input { } fn insert_text(&mut self, text: &str, cx: &mut gpui::Context) { - self.value.insert_str(self.cursor_pos, text); - self.cursor_pos += text.len(); - - if let Some(on_change) = self.on_change.clone() { - on_change(&self.value, cx); - } - self.refresh_suggestions(cx); - cx.notify(); + self.replace_selection(text, cx); } fn delete_backward(&mut self, cx: &mut gpui::Context) { + if self.has_selection() { + self.delete_selection(cx); + return; + } if self.cursor_pos == 0 { return; } let old_pos = self.cursor_pos; - self.move_left(); + self.move_left(cx, false); self.value.drain(self.cursor_pos..old_pos); if let Some(on_change) = self.on_change.clone() { @@ -298,6 +383,10 @@ impl Input { } fn delete_forward(&mut self, cx: &mut gpui::Context) { + if self.has_selection() { + self.delete_selection(cx); + return; + } if self.cursor_pos >= self.value.len() { return; } @@ -316,6 +405,10 @@ impl Input { } fn delete_word_backward(&mut self, cx: &mut gpui::Context) { + if self.has_selection() { + self.delete_selection(cx); + return; + } if self.cursor_pos == 0 { return; } @@ -332,6 +425,10 @@ impl Input { } fn delete_word_forward(&mut self, cx: &mut gpui::Context) { + if self.has_selection() { + self.delete_selection(cx); + return; + } if self.cursor_pos >= self.value.len() { return; } @@ -381,6 +478,43 @@ impl Input { return; } + if ctrl { + match key { + "a" => { + self.select_all(); + cx.notify(); + return; + } + "c" => { + if let Some(text) = self.selected_text() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.to_string())); + } + return; + } + "x" => { + if let Some(text) = self.selected_text() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.to_string())); + self.delete_selection(cx); + } + return; + } + "v" => { + if let Some(item) = cx.read_from_clipboard() { + if let Some(text) = item.text() { + let sanitized = if !self.multiline { + text.replace('\n', " ").replace("\r\n", " ") + } else { + text.replace("\r\n", "\n") + }; + self.replace_selection(&sanitized, cx); + } + } + return; + } + _ => {} + } + } + match key { "enter" => { // For multiline, Enter inserts newline (modal handles exit via commands) @@ -433,32 +567,59 @@ impl Input { "left" => { if ctrl { - self.cursor_pos = self.word_start_before(self.cursor_pos); - cx.notify(); + self.move_word_left(cx); + } else if shift { + if self.selection_anchor.is_none() { + self.selection_anchor = Some(self.cursor_pos); + } + self.move_left(cx, false); } else { - self.move_left(); - cx.notify(); + self.move_left(cx, true); } } "right" => { if ctrl { - self.cursor_pos = self.word_end_after(self.cursor_pos); - cx.notify(); + self.move_word_right(cx); + } else if shift { + if self.selection_anchor.is_none() { + self.selection_anchor = Some(self.cursor_pos); + } + self.move_right(cx, false); } else { - self.move_right(); - cx.notify(); + self.move_right(cx, true); } } "home" => { - self.cursor_pos = 0; - cx.notify(); + if shift && self.selection_anchor.is_none() { + self.selection_anchor = Some(self.cursor_pos); + self.cursor_pos = 0; + cx.notify(); + } else if shift { + self.cursor_pos = 0; + cx.notify(); + } else { + self.cursor_pos = 0; + self.clear_selection(); + cx.notify(); + } } "end" => { - self.cursor_pos = self.value.len(); - cx.notify(); + let len = self.value.len(); + if shift && self.selection_anchor.is_none() { + self.selection_anchor = Some(self.cursor_pos); + self.cursor_pos = len; + cx.notify(); + } else if shift { + self.cursor_pos = len; + cx.notify(); + } else { + self.cursor_pos = len; + self.clear_selection(); + cx.notify(); + } } "backspace" => { @@ -481,11 +642,6 @@ impl Input { self.delete_word_backward(cx); } - "a" if ctrl => { - self.cursor_pos = 0; - cx.notify(); - } - "e" if ctrl => { self.cursor_pos = self.value.len(); cx.notify(); @@ -729,22 +885,89 @@ impl gpui::Render for Input { gpui::div().id(self.id.clone()).into_any_element() }; + let content = if self.has_selection() { + let range = self.selection_range().unwrap(); + let cursor_at_end = self.cursor_pos >= range.end; + + let before_sel = &self.value[..range.start]; + let selected_text = &self.value[range.clone()]; + let after_sel = &self.value[range.end..]; + + let cursor = if is_focused { + gpui::div().w_px().h_4().bg(theme.accent).into_any_element() + } else { + gpui::div().into_any_element() + }; + + if cursor_at_end { + gpui::div() + .flex() + .flex_row() + .items_center() + .child( + gpui::div() + .text_color(theme.foreground) + .child(before_sel.to_string()), + ) + .child( + gpui::div() + .bg(theme.selection) + .text_color(theme.selection_foreground) + .child(selected_text.to_string()), + ) + .child(cursor) + .child( + gpui::div() + .text_color(theme.foreground) + .child(after_sel.to_string()), + ) + .into_any_element() + } else { + gpui::div() + .flex() + .flex_row() + .items_center() + .child( + gpui::div() + .text_color(theme.foreground) + .child(before_sel.to_string()), + ) + .child(cursor) + .child( + gpui::div() + .bg(theme.selection) + .text_color(theme.selection_foreground) + .child(selected_text.to_string()), + ) + .child( + gpui::div() + .text_color(theme.foreground) + .child(after_sel.to_string()), + ) + .into_any_element() + } + } else { + gpui::div() + .flex() + .flex_row() + .items_center() + .child( + gpui::div() + .text_color(theme.foreground) + .child(before.to_string()), + ) + .child(cursor) + .child( + gpui::div() + .text_color(theme.foreground) + .child(after.to_string()), + ) + .into_any_element() + }; + gpui::div() .id(self.id.clone()) - .flex() - .flex_row() - .items_center() - .child( - gpui::div() - .text_color(theme.foreground) - .child(before.to_string()), - ) - .child(cursor) - .child( - gpui::div() - .text_color(theme.foreground) - .child(after.to_string()), - ) + .child(content) .into_any_element() }; diff --git a/src/handler.rs b/src/handler.rs index 0956828..b8f75d4 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -401,14 +401,18 @@ impl App { annotations_add, annotations_delete, } = update; + + let has_description = description.is_some(); + let has_project = project.is_some(); + let has_priority = priority.is_some(); + let has_due = due.is_some(); + let has_tags = tags.is_some(); + let has_status = status.is_some(); + let has_annotations = !annotations_add.is_empty() || !annotations_delete.is_empty(); + let mut latest_task: Option = None; - if description.is_some() - || project.is_some() - || priority.is_some() - || tags.is_some() - || due.is_some() - { + if has_description || has_project || has_priority || has_tags || has_due { match self.task_service.update_task( task_id, description, @@ -433,39 +437,22 @@ impl App { } } - if let Some(status) = status { - let status_result = match status { - task::TaskStatus::Completed => self.task_service.complete_task(task_id), - task::TaskStatus::Pending => self.task_service.reopen_task(task_id), - task::TaskStatus::Deleted => { - self.task_service.delete_task(task_id).and_then(|_| { - self.task_service - .get_task(task_id) - .and_then(|task| task.ok_or(task::TaskError::NotFound(task_id))) - }) + if has_status { + match status { + Some(task::TaskStatus::Completed) => { + latest_task = self.task_service.complete_task(task_id).ok() } - _ => self - .task_service - .get_task(task_id) - .and_then(|task| task.ok_or(task::TaskError::NotFound(task_id))), - }; - - match status_result { - Ok(task) => latest_task = Some(task), - Err(e) => { - log::error!("[App] Failed to update status: {}", e); - self.toast_host.update(cx, |host, cx| { - host.push( - ToastKind::Error, - format!("Failed to update status: {}", e), - cx, - ); - }); - if let Some(task) = latest_task { - self.apply_task_update(task, cx); - } - return; + Some(task::TaskStatus::Pending) => { + latest_task = self.task_service.reopen_task(task_id).ok() } + Some(task::TaskStatus::Deleted) => { + let _ = self.task_service.delete_task(task_id); + latest_task = self.task_service.get_task(task_id).ok().flatten(); + } + _ => { + latest_task = self.task_service.get_task(task_id).ok().flatten(); + } + None => {} } } @@ -481,10 +468,6 @@ impl App { cx, ); }); - if let Some(task) = latest_task { - self.sync_task_detail(task, cx); - } - return; } } } @@ -501,15 +484,29 @@ impl App { cx, ); }); - if let Some(task) = latest_task { - self.sync_task_detail(task, cx); - } - return; } } } - if let Some(task) = latest_task { + let task_to_sync = if latest_task.is_some() { + latest_task.take() + } else if has_description + || has_project + || has_priority + || has_tags + || has_due + || has_status + || has_annotations + { + self.task_service.get_task(task_id).ok().flatten() + } else { + self.task_detail_modal.update(cx, |modal, cx| { + modal.cancel_edit(None, cx); + }); + return; + }; + + if let Some(task) = task_to_sync { self.apply_task_update(task, cx); } else { self.task_detail_modal.update(cx, |modal, cx| { diff --git a/src/task/service.rs b/src/task/service.rs index 3286cf4..2be1d2e 100644 --- a/src/task/service.rs +++ b/src/task/service.rs @@ -610,10 +610,12 @@ impl TaskService { .map_err(|e| TaskError::Storage(e.to_string()))? .ok_or(TaskError::NotFound(uuid))?; - let annotation = taskchampion::Annotation { - entry: Utc::now(), - description, - }; + let mut entry = Utc::now(); + while tc_task.get_annotations().any(|a| a.entry == entry) { + entry = entry + chrono::Duration::nanoseconds(1); + } + + let annotation = taskchampion::Annotation { entry, description }; tc_task .add_annotation(annotation, &mut ops) diff --git a/src/view/task_detail_modal/annotations.rs b/src/view/task_detail_modal/annotations.rs index 0526fe6..f7cc185 100644 --- a/src/view/task_detail_modal/annotations.rs +++ b/src/view/task_detail_modal/annotations.rs @@ -41,11 +41,10 @@ pub(super) enum AnnotationOrigin { }, } -fn annotation_id(entry: DateTime, text: &str, index: usize) -> AnnotationId { +fn annotation_id(entry: DateTime, text: &str) -> AnnotationId { let mut hasher = DefaultHasher::new(); entry.timestamp_millis().hash(&mut hasher); text.hash(&mut hasher); - index.hash(&mut hasher); hasher.finish() } @@ -54,9 +53,8 @@ impl AnnotationState { let items: Vec = detail .annotations .iter() - .enumerate() - .map(|(index, annotation)| AnnotationView { - id: annotation_id(annotation.entry, &annotation.content, index), + .map(|annotation| AnnotationView { + id: annotation_id(annotation.entry, &annotation.content), created_at: annotation.entry, text: annotation.content.clone().into(), origin: AnnotationOrigin::Original, @@ -78,7 +76,7 @@ impl AnnotationState { } pub(super) fn add_local(&mut self, text: SharedString, created_at: DateTime) { - let id = annotation_id(created_at, text.as_ref(), self.items.len()); + let id = annotation_id(created_at, text.as_ref()); self.items.push(AnnotationView { id, created_at, diff --git a/src/view/task_detail_modal/mod.rs b/src/view/task_detail_modal/mod.rs index d3b4e07..869060a 100644 --- a/src/view/task_detail_modal/mod.rs +++ b/src/view/task_detail_modal/mod.rs @@ -877,6 +877,8 @@ impl TaskDetailModal { self.entities.annotation_input.update(cx, |input, cx| { input.clear(cx); }); + + self.state.annotation_selected = None; cx.notify(); } @@ -1334,6 +1336,10 @@ impl TaskDetailModal { match self.state.modal_focus { ModalFocus::TagsInput => { + self.entities.tags_input.update(cx, |input, cx| { + input.close_suggestions(cx); + }); + self.sync_form_from_inputs(cx); if let Some(InlineEditTarget::Tag(i)) = self.state.inline_edit { @@ -1581,7 +1587,6 @@ impl TaskDetailModal { ConfirmAction::DeleteTag { index, .. } => { if index < self.state.form.tags.len() { self.state.form.tags.remove(index); - // Adjust selection if self.state.form.tags.is_empty() { self.state.tag_selected = None; } else if let Some(sel) = self.state.tag_selected { @@ -1710,16 +1715,6 @@ impl TaskDetailModal { match ann.origin { AnnotationOrigin::Added => { - self.state.inline_edit = - Some(InlineEditTarget::Annotation(actual_index)); - - let ann_value = ann.text.to_string(); - self.entities.annotation_input.update(cx, |input, cx| { - input.set_value(ann_value, cx); - }); - - self.enter_edit_field(window, cx); - return CommandResult::Handled; } AnnotationOrigin::Original => { diff --git a/src/view/task_detail_modal/render/mod.rs b/src/view/task_detail_modal/render/mod.rs index e28d3e9..1ac2e92 100644 --- a/src/view/task_detail_modal/render/mod.rs +++ b/src/view/task_detail_modal/render/mod.rs @@ -61,6 +61,7 @@ pub(super) fn render_task_detail_modal( tags_input, status_dropdown, priority_dropdown, + focus_handle, form_focus_handle, scroll_handle, &theme, diff --git a/src/view/task_detail_modal/render/panel.rs b/src/view/task_detail_modal/render/panel.rs index 825c0ee..2f83beb 100644 --- a/src/view/task_detail_modal/render/panel.rs +++ b/src/view/task_detail_modal/render/panel.rs @@ -120,6 +120,7 @@ pub(super) fn render_task_detail_panel( tags_input: &gpui::Entity, status_dropdown: &gpui::Entity, priority_dropdown: &gpui::Entity, + focus_handle: &gpui::FocusHandle, form_focus_handle: &gpui::FocusHandle, scroll_handle: &gpui::ScrollHandle, theme: &Theme, @@ -300,7 +301,11 @@ where .min_h_0() .overflow_y_scroll() .track_scroll(scroll_handle) - .track_focus(form_focus_handle) + .track_focus(if mode == ModalMode::View { + focus_handle + } else { + form_focus_handle + }) .px(gpui::rems(1.0)) .py(gpui::rems(0.75)) .gap_4() @@ -678,6 +683,7 @@ fn render_tags_section( gpui::div() .flex() + .flex_wrap() .items_center() .gap_2() .children(chips) @@ -694,6 +700,7 @@ fn render_tags_section( gpui::div() .flex() + .flex_wrap() .gap_2() .children(chips) .into_any_element() @@ -711,7 +718,14 @@ fn render_tags_section( .custom(Theme::alpha(theme.muted, 0.2), theme.muted) .into_any_element() }); - div.child(gpui::div().flex().gap_2().children(vchips).text_sm()) + div.child( + gpui::div() + .flex() + .flex_wrap() + .gap_2() + .children(vchips) + .text_sm(), + ) }), ) } diff --git a/src/view/task_table.rs b/src/view/task_table.rs index 1670e2d..fcdb79e 100644 --- a/src/view/task_table.rs +++ b/src/view/task_table.rs @@ -108,7 +108,7 @@ impl Default for SortState { fn default() -> Self { Self { column: SortColumn::Priority, - direction: SortDirection::Desc, + direction: SortDirection::Asc, } } }