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.
383 lines
11 KiB
Rust
383 lines
11 KiB
Rust
use std::sync::Arc;
|
|
|
|
use gpui::prelude::*;
|
|
use gpui::{Corner, anchored, deferred, point, px};
|
|
|
|
use crate::components::button::Button;
|
|
use crate::components::label::Label;
|
|
use crate::theme::ActiveTheme;
|
|
use crate::ui::{clickable_control_style, disabled_control_style};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct DropdownItem {
|
|
pub label: gpui::SharedString,
|
|
pub value: gpui::SharedString,
|
|
}
|
|
|
|
impl DropdownItem {
|
|
pub fn new(label: impl Into<gpui::SharedString>) -> Self {
|
|
let label = label.into();
|
|
Self {
|
|
value: label.clone(),
|
|
label,
|
|
}
|
|
}
|
|
|
|
pub fn with_value(
|
|
label: impl Into<gpui::SharedString>,
|
|
value: impl Into<gpui::SharedString>,
|
|
) -> Self {
|
|
Self {
|
|
label: label.into(),
|
|
value: value.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct Dropdown {
|
|
id: gpui::ElementId,
|
|
button: Option<Button>,
|
|
items: Vec<DropdownItem>,
|
|
open: bool,
|
|
selected_index: Option<usize>,
|
|
disabled: bool,
|
|
loading: bool,
|
|
inline_menu: bool,
|
|
placeholder: gpui::SharedString,
|
|
label_prefix: Option<gpui::SharedString>,
|
|
on_select: Option<Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>>,
|
|
}
|
|
|
|
impl Dropdown {
|
|
pub fn new(id: impl Into<gpui::ElementId>) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
button: None,
|
|
items: Vec::new(),
|
|
open: false,
|
|
selected_index: None,
|
|
disabled: false,
|
|
loading: false,
|
|
inline_menu: false,
|
|
placeholder: "Select".into(),
|
|
label_prefix: None,
|
|
on_select: None,
|
|
}
|
|
}
|
|
|
|
pub fn button(mut self, button: Button) -> Self {
|
|
self.button = Some(button);
|
|
self
|
|
}
|
|
|
|
pub fn item(mut self, item: DropdownItem) -> Self {
|
|
self.items.push(item);
|
|
self
|
|
}
|
|
|
|
pub fn items<I>(mut self, items: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = DropdownItem>,
|
|
{
|
|
self.items = items.into_iter().collect();
|
|
self
|
|
}
|
|
|
|
pub fn placeholder(mut self, placeholder: impl Into<gpui::SharedString>) -> Self {
|
|
self.placeholder = placeholder.into();
|
|
self
|
|
}
|
|
|
|
pub fn label_prefix(mut self, prefix: impl Into<gpui::SharedString>) -> Self {
|
|
self.label_prefix = Some(prefix.into());
|
|
self
|
|
}
|
|
|
|
pub fn selected_index(mut self, index: usize) -> Self {
|
|
self.selected_index = Some(index);
|
|
self
|
|
}
|
|
|
|
pub fn selected_index_value(&self) -> Option<usize> {
|
|
self.selected_index
|
|
}
|
|
|
|
pub fn selected_item(&self) -> Option<&DropdownItem> {
|
|
self.selected_index.and_then(|index| self.items.get(index))
|
|
}
|
|
|
|
pub fn is_open(&self) -> bool {
|
|
self.open
|
|
}
|
|
|
|
pub fn disabled(mut self, disabled: bool) -> Self {
|
|
self.disabled = disabled;
|
|
self
|
|
}
|
|
|
|
pub fn loading(mut self, loading: bool) -> Self {
|
|
self.loading = loading;
|
|
self
|
|
}
|
|
|
|
pub fn inline_menu(mut self) -> Self {
|
|
self.inline_menu = true;
|
|
self
|
|
}
|
|
|
|
pub fn on_select(
|
|
mut self,
|
|
handler: Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>,
|
|
) -> Self {
|
|
self.on_select = Some(handler);
|
|
self
|
|
}
|
|
|
|
pub fn selected_label(&self) -> Option<gpui::SharedString> {
|
|
self.selected_index
|
|
.and_then(|index| self.items.get(index).map(|item| item.label.clone()))
|
|
}
|
|
|
|
pub fn set_selected_index(&mut self, index: usize, cx: &mut gpui::Context<Self>) {
|
|
if index < self.items.len() {
|
|
self.selected_index = Some(index);
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn set_items(&mut self, items: Vec<DropdownItem>, cx: &mut gpui::Context<Self>) {
|
|
self.items = items;
|
|
if let Some(selected) = self.selected_index {
|
|
if selected >= self.items.len() {
|
|
self.selected_index = None;
|
|
}
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn toggle_open(&mut self, cx: &mut gpui::Context<Self>) {
|
|
if self.disabled || self.loading || self.items.is_empty() {
|
|
return;
|
|
}
|
|
self.open = !self.open;
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn open(&mut self, cx: &mut gpui::Context<Self>) {
|
|
if !self.disabled && !self.loading && !self.items.is_empty() {
|
|
self.open = true;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn close(&mut self, cx: &mut gpui::Context<Self>) {
|
|
self.open = false;
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn select_next_item(&mut self, cx: &mut gpui::Context<Self>) {
|
|
if self.items.is_empty() {
|
|
return;
|
|
}
|
|
let next_index = self
|
|
.selected_index
|
|
.map(|i| (i + 1) % self.items.len())
|
|
.unwrap_or(0);
|
|
self.set_selected_index(next_index, cx);
|
|
}
|
|
|
|
pub fn select_prev_item(&mut self, cx: &mut gpui::Context<Self>) {
|
|
if self.items.is_empty() {
|
|
return;
|
|
}
|
|
let prev_index = self
|
|
.selected_index
|
|
.map(|i| if i == 0 { self.items.len() - 1 } else { i - 1 })
|
|
.unwrap_or(self.items.len() - 1);
|
|
self.set_selected_index(prev_index, cx);
|
|
}
|
|
|
|
pub fn accept_selection(&mut self, cx: &mut gpui::Context<Self>) {
|
|
if let Some(index) = self.selected_index {
|
|
self.select_item(index, cx);
|
|
}
|
|
}
|
|
|
|
fn select_item(&mut self, index: usize, cx: &mut gpui::Context<Self>) {
|
|
if self.disabled || self.loading {
|
|
return;
|
|
}
|
|
let item = match self.items.get(index) {
|
|
Some(item) => item,
|
|
None => return,
|
|
};
|
|
self.selected_index = Some(index);
|
|
self.open = false;
|
|
if let Some(on_select) = self.on_select.clone() {
|
|
on_select(index, item, cx);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn handle_trigger_mouse_down(
|
|
&mut self,
|
|
_event: &gpui::MouseDownEvent,
|
|
_window: &mut gpui::Window,
|
|
cx: &mut gpui::Context<Self>,
|
|
) {
|
|
self.toggle_open(cx);
|
|
}
|
|
|
|
fn handle_mouse_down_out(
|
|
&mut self,
|
|
_event: &gpui::MouseDownEvent,
|
|
_window: &mut gpui::Window,
|
|
cx: &mut gpui::Context<Self>,
|
|
) {
|
|
if self.open {
|
|
self.open = false;
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn render_menu(&self, cx: &gpui::Context<Self>) -> gpui::AnyElement {
|
|
if !self.open || self.items.is_empty() {
|
|
return gpui::div().into_any_element();
|
|
}
|
|
|
|
let theme = cx.theme();
|
|
let is_disabled = self.disabled || self.loading;
|
|
let items: Vec<gpui::AnyElement> = self
|
|
.items
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, item)| {
|
|
let is_selected = self.selected_index == Some(index);
|
|
let mut row = gpui::div()
|
|
.id(index)
|
|
.w_full()
|
|
.px_2()
|
|
.py_1()
|
|
.text_sm()
|
|
.whitespace_nowrap()
|
|
.bg(theme.background)
|
|
.text_color(if is_selected {
|
|
theme.selection_foreground
|
|
} else {
|
|
theme.foreground
|
|
})
|
|
.when(is_selected, |el| el.bg(theme.selection))
|
|
.when(!is_disabled, |el| {
|
|
el.hover(|s: gpui::StyleRefinement| s.bg(theme.selection))
|
|
})
|
|
.child(item.label.clone());
|
|
|
|
if is_disabled {
|
|
row = row.text_color(theme.muted).cursor_not_allowed();
|
|
} else {
|
|
row = row.cursor_pointer().on_mouse_down(
|
|
gpui::MouseButton::Left,
|
|
cx.listener(move |this, _event, _window, cx| {
|
|
this.select_item(index, cx);
|
|
}),
|
|
);
|
|
}
|
|
|
|
row.into_any_element()
|
|
})
|
|
.collect();
|
|
|
|
let menu = gpui::div()
|
|
.w_full() // Same width as trigger
|
|
.p_1()
|
|
.border_1()
|
|
.border_color(theme.border)
|
|
.bg(theme.background)
|
|
.rounded_md()
|
|
.overflow_hidden()
|
|
.shadow_lg()
|
|
.occlude()
|
|
.children(items);
|
|
|
|
if self.inline_menu {
|
|
menu.into_any_element()
|
|
} else {
|
|
deferred(
|
|
anchored()
|
|
.anchor(Corner::TopLeft)
|
|
.offset(point(px(0.0), px(4.0)))
|
|
.snap_to_window()
|
|
.child(menu),
|
|
)
|
|
.with_priority(1)
|
|
.into_any_element()
|
|
}
|
|
}
|
|
}
|
|
|
|
impl gpui::Render for Dropdown {
|
|
fn render(
|
|
&mut self,
|
|
_window: &mut gpui::Window,
|
|
cx: &mut gpui::Context<Self>,
|
|
) -> impl IntoElement {
|
|
let theme = cx.theme();
|
|
let is_disabled = self.disabled || self.loading;
|
|
let disabled = is_disabled || self.items.is_empty();
|
|
let mut label = self
|
|
.selected_label()
|
|
.unwrap_or_else(|| self.placeholder.clone());
|
|
if let Some(prefix) = &self.label_prefix {
|
|
label = format!("{}: {}", prefix, label).into();
|
|
}
|
|
|
|
let arrow = "▾";
|
|
|
|
let trigger: gpui::AnyElement = if let Some(button) = self.button.clone() {
|
|
let mut button = button;
|
|
button.on_click = None;
|
|
button.label = Some(format!("{} {}", label, arrow).into());
|
|
button
|
|
.disabled(disabled)
|
|
.loading(self.loading)
|
|
.bg(theme.background)
|
|
.border_color(theme.border)
|
|
.text_color(theme.foreground)
|
|
.into_any_element()
|
|
} else {
|
|
let base = gpui::div()
|
|
.child(Label::new(label.clone()))
|
|
.child(Label::new(arrow).text_color(theme.muted));
|
|
|
|
let trigger = if disabled {
|
|
disabled_control_style(base, theme)
|
|
} else {
|
|
clickable_control_style(base, theme)
|
|
};
|
|
|
|
trigger.into_any_element()
|
|
};
|
|
|
|
let mut trigger_wrap = gpui::div().flex_shrink_0().child(trigger);
|
|
if !disabled {
|
|
trigger_wrap = trigger_wrap.on_mouse_down(
|
|
gpui::MouseButton::Left,
|
|
cx.listener(Self::handle_trigger_mouse_down),
|
|
);
|
|
}
|
|
|
|
let mut container = gpui::div()
|
|
.id(self.id.clone())
|
|
.flex()
|
|
.flex_col()
|
|
.child(trigger_wrap)
|
|
.child(self.render_menu(cx));
|
|
|
|
if self.open {
|
|
container = container.on_mouse_down_out(cx.listener(Self::handle_mouse_down_out));
|
|
}
|
|
|
|
container
|
|
}
|
|
}
|