1429 lines
55 KiB
Rust
1429 lines
55 KiB
Rust
use std::cmp;
|
|
use std::collections::HashMap;
|
|
use std::fmt::Debug;
|
|
use std::sync::Arc;
|
|
|
|
use instant::Instant;
|
|
use pathfinder_geometry::vector::vec2f;
|
|
|
|
use crate::{
|
|
ai::cloud_environments::CloudAmbientAgentEnvironment,
|
|
cloud_object::model::generic_string_model::StringModel,
|
|
editor::{
|
|
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
|
|
TextOptions,
|
|
},
|
|
server::ids::{ClientId, HashableId, ServerId, SyncId},
|
|
ui_components::icons::Icon,
|
|
view_components::copyable_text_field::{
|
|
render_copyable_text_field, CopyButtonPlacement, CopyableTextFieldConfig,
|
|
COPY_FEEDBACK_DURATION,
|
|
},
|
|
};
|
|
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
|
|
use galaxy_core::ui::theme::color::internal_colors;
|
|
use galaxy_core::ui::{appearance::Appearance, builder::MIN_FONT_SIZE, theme::Fill};
|
|
use galaxy_editor::editor::NavigationKey;
|
|
use galaxyui::units::Pixels;
|
|
use galaxyui::{
|
|
color::ColorU,
|
|
elements::Highlight,
|
|
fonts::{Properties, Weight},
|
|
ui_components::components::{Coords, UiComponentStyles},
|
|
};
|
|
use galaxyui::{
|
|
elements::{
|
|
Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable,
|
|
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult,
|
|
DropShadow, Empty, EventHandler, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
|
|
MouseInBehavior, MouseStateHandle, OffsetPositioning, ParentElement,
|
|
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition,
|
|
ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Shrinkable, Stack, Text,
|
|
UniformList, UniformListState,
|
|
},
|
|
keymap::FixedBinding,
|
|
ui_components::components::UiComponent,
|
|
AppContext, Element, Entity, FocusContext, SingletonEntity as _, TypedActionView, View,
|
|
ViewContext, ViewHandle, WindowId,
|
|
};
|
|
|
|
use galaxyui::clipboard::ClipboardContent;
|
|
use galaxyui::r#async::Timer;
|
|
|
|
/// Trait for items that can be displayed in a generic menu
|
|
pub trait GenericMenuItem: Debug + 'static {
|
|
/// Enable downcasting to concrete types
|
|
fn as_any(&self) -> &dyn std::any::Any;
|
|
|
|
/// Display name for the menu item
|
|
fn name(&self) -> String;
|
|
|
|
/// Icon to display for the menu item (None for no icon)
|
|
fn icon(&self, _app: &AppContext) -> Option<Icon>;
|
|
|
|
/// Data associated with this menu item action
|
|
fn action_data(&self) -> String;
|
|
|
|
/// Optional element to render on the right side of the menu item
|
|
fn right_side_element(&self, _app: &AppContext) -> Option<Box<dyn Element>> {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FixedFooter {
|
|
action_item: Arc<dyn GenericMenuItem>,
|
|
mouse_state: MouseStateHandle,
|
|
}
|
|
|
|
impl FixedFooter {
|
|
pub fn new(action_item: Arc<dyn GenericMenuItem>) -> Self {
|
|
Self {
|
|
action_item,
|
|
mouse_state: Default::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, PartialEq)]
|
|
pub enum ChipMenuType {
|
|
Directories,
|
|
Branches,
|
|
CodeReview,
|
|
Environments,
|
|
}
|
|
|
|
const LABEL_HORIZONTAL_PADDING: f32 = 14.;
|
|
const SEARCH_INPUT_HORIZONTAL_PADDING: f32 = 8.;
|
|
const LABEL_VERTICAL_PADDING: f32 = 5.;
|
|
const MENU_VERTICAL_PADDING: f32 = 9.;
|
|
const MENU_WIDTH: f32 = 360.;
|
|
|
|
// Environments menu sizing from Figma mock.
|
|
const ENV_MENU_WIDTH: f32 = 321.;
|
|
const ENV_MENU_MAX_HEIGHT: f32 = 200.;
|
|
const ENV_MENU_VERTICAL_PADDING: f32 = 4.;
|
|
const ENV_MENU_ITEM_HORIZONTAL_PADDING: f32 = 16.;
|
|
const ENV_MENU_ITEM_VERTICAL_PADDING: f32 = 4.;
|
|
const ENV_MENU_ICON_SIZE: f32 = 16.;
|
|
const ENV_MENU_ICON_SLOT_SIZE: f32 = 16.;
|
|
const ENV_MENU_ITEM_FONT_SIZE: f32 = 14.;
|
|
const ENV_MENU_SEARCH_VERTICAL_PADDING: f32 = 4.;
|
|
// Bottom padding under the search field. The model selector's bottom padding
|
|
// is effectively `SEARCH_VERTICAL_PADDING (4) + MENU_CONTENT_VERTICAL_PADDING
|
|
// (4) = 8` because its `Menu` wraps the pinned footer in another 4px of
|
|
// content padding. We don't have that wrapper, so we bake the same 8px
|
|
// directly into the footer container.
|
|
const ENV_MENU_SEARCH_BOTTOM_PADDING: f32 = 8.;
|
|
const ENV_MENU_SEARCH_FOOTER_TOP_MARGIN: f32 = 4.;
|
|
|
|
// Environments sidecar sizing from Figma mock.
|
|
const ENV_SIDE_CAR_WIDTH: f32 = 320.;
|
|
const ENV_SIDE_CAR_HEIGHT: f32 = 108.;
|
|
const ENV_SIDE_CAR_HORIZONTAL_GAP: f32 = 1.;
|
|
const ENV_SIDE_CAR_PADDING: f32 = 12.;
|
|
const ENV_SIDE_CAR_ROW_GAP: f32 = 8.;
|
|
const ENV_SIDE_CAR_ICON_LABEL_GAP: f32 = 4.;
|
|
const ENV_SIDE_CAR_ICON_SIZE: f32 = 12.;
|
|
const ENV_SIDE_CAR_COPY_BUTTON_SIZE: f32 = 16.;
|
|
const ENV_SIDE_CAR_OUTER_RADIUS: f32 = 6.;
|
|
const ENV_SIDE_CAR_INNER_RADIUS: f32 = 4.;
|
|
|
|
pub fn init(app: &mut AppContext) {
|
|
use galaxyui::keymap::macros::*;
|
|
|
|
app.register_fixed_bindings([
|
|
FixedBinding::new(
|
|
"up",
|
|
DisplayChipMenuAction::SelectUp,
|
|
id!(DisplayChipMenu::ui_name()),
|
|
),
|
|
FixedBinding::new(
|
|
"down",
|
|
DisplayChipMenuAction::SelectDown,
|
|
id!(DisplayChipMenu::ui_name()),
|
|
),
|
|
FixedBinding::new(
|
|
"escape",
|
|
DisplayChipMenuAction::Close,
|
|
id!(DisplayChipMenu::ui_name()),
|
|
),
|
|
FixedBinding::new(
|
|
"enter",
|
|
DisplayChipMenuAction::SelectEnter,
|
|
id!(DisplayChipMenu::ui_name()),
|
|
),
|
|
]);
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct FilteredMenuItem {
|
|
item: Arc<dyn GenericMenuItem>,
|
|
match_result: Option<FuzzyMatchResult>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct EnvironmentSidecarData {
|
|
name: String,
|
|
id: String,
|
|
image: String,
|
|
repos_text: String,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
enum EnvironmentSidecarSide {
|
|
Left,
|
|
Right,
|
|
}
|
|
|
|
pub struct DisplayChipMenu {
|
|
list_state: UniformListState,
|
|
scroll_state: ScrollStateHandle,
|
|
menu_items: Vec<Arc<dyn GenericMenuItem>>,
|
|
filtered_items: Vec<FilteredMenuItem>,
|
|
selected_index: usize,
|
|
is_footer_selected: bool,
|
|
fixed_footer: Option<FixedFooter>,
|
|
search_input: Option<ViewHandle<EditorView>>,
|
|
search_query: String,
|
|
chip_menu_type: ChipMenuType,
|
|
|
|
// Environment sidecar state
|
|
window_id: WindowId,
|
|
env_sidecar_copy_id_mouse_state: MouseStateHandle,
|
|
env_sidecar_copy_image_mouse_state: MouseStateHandle,
|
|
env_sidecar_copy_feedback_times: HashMap<String, Instant>,
|
|
env_sidecar_scroll_state: ClippedScrollStateHandle,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum DisplayChipMenuAction {
|
|
SelectItem { index: usize },
|
|
Select { index: usize },
|
|
SelectUp,
|
|
SelectDown,
|
|
SelectEnter,
|
|
SelectFixedFooterOption,
|
|
CopyEnvironmentSidecarField { key: String, value: String },
|
|
Close,
|
|
}
|
|
|
|
impl DisplayChipMenu {
|
|
fn menu_width(&self) -> f32 {
|
|
match self.chip_menu_type {
|
|
ChipMenuType::Environments => ENV_MENU_WIDTH,
|
|
ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => {
|
|
MENU_WIDTH
|
|
}
|
|
}
|
|
}
|
|
|
|
fn menu_item_horizontal_padding(&self) -> f32 {
|
|
match self.chip_menu_type {
|
|
ChipMenuType::Environments => ENV_MENU_ITEM_HORIZONTAL_PADDING,
|
|
ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => {
|
|
LABEL_HORIZONTAL_PADDING
|
|
}
|
|
}
|
|
}
|
|
|
|
fn menu_item_vertical_padding(&self) -> f32 {
|
|
match self.chip_menu_type {
|
|
ChipMenuType::Environments => ENV_MENU_ITEM_VERTICAL_PADDING,
|
|
ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => {
|
|
LABEL_VERTICAL_PADDING
|
|
}
|
|
}
|
|
}
|
|
|
|
fn menu_vertical_padding(&self) -> f32 {
|
|
match self.chip_menu_type {
|
|
ChipMenuType::Environments => ENV_MENU_VERTICAL_PADDING,
|
|
ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => {
|
|
MENU_VERTICAL_PADDING
|
|
}
|
|
}
|
|
}
|
|
|
|
fn figma_menu_drop_shadow() -> DropShadow {
|
|
DropShadow::default()
|
|
}
|
|
|
|
pub fn new<T: GenericMenuItem>(
|
|
menu_items: Vec<T>,
|
|
fixed_footer_option: Option<FixedFooter>,
|
|
chip_menu_type: ChipMenuType,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let search_input = match chip_menu_type {
|
|
ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::Environments => {
|
|
Some(ctx.add_typed_action_view(|ctx| {
|
|
let appearance = Appearance::handle(ctx).as_ref(ctx);
|
|
|
|
let text_options = match chip_menu_type {
|
|
ChipMenuType::Environments => {
|
|
TextOptions::ui_text(Some(ENV_MENU_ITEM_FONT_SIZE), appearance)
|
|
}
|
|
ChipMenuType::Directories
|
|
| ChipMenuType::Branches
|
|
| ChipMenuType::CodeReview => {
|
|
let ui_font_family = appearance.ui_font_family();
|
|
let mut options = TextOptions::ui_font_size(appearance);
|
|
options.font_family_override = Some(ui_font_family);
|
|
options
|
|
}
|
|
};
|
|
|
|
let options = EditorOptions {
|
|
autogrow: false,
|
|
soft_wrap: false,
|
|
single_line: true,
|
|
text: text_options,
|
|
propagate_and_no_op_vertical_navigation_keys:
|
|
PropagateAndNoOpNavigationKeys::Always,
|
|
..Default::default()
|
|
};
|
|
let mut editor = EditorView::new(options, ctx);
|
|
let placeholder_text = match chip_menu_type {
|
|
ChipMenuType::Directories => "Search directories...",
|
|
ChipMenuType::Branches => "Search branches...",
|
|
ChipMenuType::Environments => "Search environments...",
|
|
ChipMenuType::CodeReview => {
|
|
unreachable!("search input should not be constructed")
|
|
}
|
|
};
|
|
editor.set_placeholder_text(placeholder_text, ctx);
|
|
editor
|
|
}))
|
|
}
|
|
ChipMenuType::CodeReview => None,
|
|
};
|
|
|
|
// Subscribe to editor changes to update search query (only if search input exists)
|
|
if let Some(ref search_input_handle) = search_input {
|
|
ctx.subscribe_to_view(
|
|
search_input_handle,
|
|
|menu, _editor, event, ctx| match event {
|
|
EditorEvent::Edited(_) => {
|
|
if let Some(ref search_input) = menu.search_input {
|
|
let new_query = search_input
|
|
.read(ctx, |editor, ctx| editor.buffer_text(ctx).to_string());
|
|
if new_query != menu.search_query {
|
|
menu.update_search_query(new_query, ctx);
|
|
}
|
|
}
|
|
}
|
|
EditorEvent::Escape => {
|
|
menu.close(ctx);
|
|
}
|
|
EditorEvent::Navigate(NavigationKey::Up) => {
|
|
menu.select_prev(ctx);
|
|
}
|
|
EditorEvent::Navigate(NavigationKey::Down) => {
|
|
menu.select_next(ctx);
|
|
}
|
|
EditorEvent::Enter => {
|
|
menu.select_enter(ctx);
|
|
}
|
|
_ => {}
|
|
},
|
|
);
|
|
}
|
|
|
|
let menu_items: Vec<Arc<dyn GenericMenuItem>> = menu_items
|
|
.into_iter()
|
|
.map(|value| {
|
|
let arc: Arc<dyn GenericMenuItem> = Arc::new(value);
|
|
arc
|
|
})
|
|
.collect();
|
|
|
|
let filtered_items: Vec<FilteredMenuItem> = menu_items
|
|
.iter()
|
|
.map(|item| FilteredMenuItem {
|
|
item: item.clone(),
|
|
match_result: None,
|
|
})
|
|
.collect();
|
|
|
|
// Always start selection at the top (first item) for consistent behavior
|
|
let initial_selected_index = 0;
|
|
|
|
Self {
|
|
list_state: Default::default(),
|
|
scroll_state: Default::default(),
|
|
menu_items,
|
|
filtered_items,
|
|
selected_index: initial_selected_index,
|
|
fixed_footer: fixed_footer_option,
|
|
is_footer_selected: false,
|
|
search_input,
|
|
search_query: String::new(),
|
|
chip_menu_type,
|
|
|
|
window_id: ctx.window_id(),
|
|
env_sidecar_copy_id_mouse_state: Default::default(),
|
|
env_sidecar_copy_image_mouse_state: Default::default(),
|
|
env_sidecar_copy_feedback_times: HashMap::new(),
|
|
env_sidecar_scroll_state: Default::default(),
|
|
}
|
|
}
|
|
|
|
pub fn reset_selected_index(&mut self) {
|
|
if self.filtered_items.is_empty() && self.fixed_footer.is_some() {
|
|
self.is_footer_selected = true;
|
|
return;
|
|
}
|
|
self.selected_index = 0;
|
|
self.is_footer_selected = false;
|
|
self.env_sidecar_scroll_state.scroll_to(Pixels::zero());
|
|
}
|
|
|
|
/// Update the menu items and reset the selected index
|
|
pub fn update_menu_items<T: GenericMenuItem>(
|
|
&mut self,
|
|
new_items: Vec<T>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.menu_items = new_items
|
|
.into_iter()
|
|
.map(|value| {
|
|
let arc: Arc<dyn GenericMenuItem> = Arc::new(value);
|
|
arc
|
|
})
|
|
.collect();
|
|
self.update_filtered_items();
|
|
self.reset_selected_index();
|
|
|
|
// Scroll to the selected item
|
|
if !self.filtered_items.is_empty() {
|
|
self.list_state.scroll_to(self.selected_index);
|
|
}
|
|
|
|
ctx.notify();
|
|
}
|
|
|
|
fn update_filtered_items(&mut self) {
|
|
if self.search_query.is_empty() {
|
|
// No search query - show all items
|
|
self.filtered_items = self
|
|
.menu_items
|
|
.iter()
|
|
.map(|item| FilteredMenuItem {
|
|
item: item.clone(),
|
|
match_result: None,
|
|
})
|
|
.collect();
|
|
} else {
|
|
// Filter items based on search query
|
|
self.filtered_items = self
|
|
.menu_items
|
|
.iter()
|
|
.filter_map(|item| {
|
|
let item_name = item.name();
|
|
match_indices_case_insensitive(&item_name, &self.search_query).map(
|
|
|match_result| FilteredMenuItem {
|
|
item: item.clone(),
|
|
match_result: Some(match_result),
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
// Sort by match score (higher scores first)
|
|
self.filtered_items.sort_by(|a, b| {
|
|
let score_a = a.match_result.as_ref().map(|r| r.score).unwrap_or(0);
|
|
let score_b = b.match_result.as_ref().map(|r| r.score).unwrap_or(0);
|
|
score_b.cmp(&score_a)
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn update_search_query(&mut self, query: String, ctx: &mut ViewContext<Self>) {
|
|
self.search_query = query;
|
|
self.update_filtered_items();
|
|
|
|
// Always start at the top after filtering for consistent behavior
|
|
self.reset_selected_index();
|
|
if !self.filtered_items.is_empty() {
|
|
self.list_state.scroll_to(self.selected_index);
|
|
}
|
|
|
|
ctx.notify();
|
|
}
|
|
|
|
fn select_item(&mut self, item: Arc<dyn GenericMenuItem>, ctx: &mut ViewContext<Self>) {
|
|
ctx.emit(PromptDisplayMenuEvent::MenuAction(GenericMenuEvent {
|
|
action_item: item.clone(),
|
|
}));
|
|
ctx.notify();
|
|
}
|
|
|
|
fn select(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
|
if self.selected_index != index {
|
|
self.selected_index = index;
|
|
self.env_sidecar_scroll_state.scroll_to(Pixels::zero());
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
fn is_footer_selected(&self) -> bool {
|
|
self.is_footer_selected
|
|
|| self
|
|
.fixed_footer
|
|
.as_ref()
|
|
.is_some_and(|f| f.mouse_state.lock().is_ok_and(|state| state.is_hovered()))
|
|
}
|
|
|
|
fn select_prev(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if self.filtered_items.is_empty() {
|
|
return;
|
|
}
|
|
let has_footer = self.fixed_footer.is_some();
|
|
|
|
if self.selected_index == 0 {
|
|
if has_footer && !self.is_footer_selected() {
|
|
self.is_footer_selected = true;
|
|
} else {
|
|
self.is_footer_selected = false;
|
|
self.selected_index = self.filtered_items.len() - 1;
|
|
self.env_sidecar_scroll_state.scroll_to(Pixels::zero());
|
|
}
|
|
} else {
|
|
self.is_footer_selected = false;
|
|
self.selected_index -= 1;
|
|
self.env_sidecar_scroll_state.scroll_to(Pixels::zero());
|
|
}
|
|
self.list_state.scroll_to(self.selected_index);
|
|
ctx.notify();
|
|
}
|
|
|
|
fn select_next(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if self.filtered_items.is_empty() {
|
|
return;
|
|
}
|
|
let has_footer = self.fixed_footer.is_some();
|
|
|
|
self.selected_index += 1;
|
|
if self.is_footer_selected() {
|
|
self.is_footer_selected = false;
|
|
self.selected_index = 0;
|
|
self.env_sidecar_scroll_state.scroll_to(Pixels::zero());
|
|
} else if self.selected_index >= self.filtered_items.len() {
|
|
self.selected_index = 0;
|
|
self.env_sidecar_scroll_state.scroll_to(Pixels::zero());
|
|
if has_footer && !self.is_footer_selected() {
|
|
self.is_footer_selected = true;
|
|
}
|
|
} else {
|
|
self.env_sidecar_scroll_state.scroll_to(Pixels::zero());
|
|
}
|
|
self.list_state.scroll_to(self.selected_index);
|
|
ctx.notify();
|
|
}
|
|
|
|
fn select_enter(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if self.is_footer_selected() {
|
|
self.select_fixed_footer_option(ctx);
|
|
return;
|
|
}
|
|
|
|
if self.selected_index < self.filtered_items.len() {
|
|
let item = self.filtered_items[self.selected_index].item.clone();
|
|
self.select_item(item, ctx);
|
|
}
|
|
}
|
|
|
|
fn select_fixed_footer_option(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if let Some(footer_option) = &self.fixed_footer {
|
|
ctx.emit(PromptDisplayMenuEvent::MenuAction(GenericMenuEvent {
|
|
action_item: footer_option.action_item.clone(),
|
|
}));
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
fn close(&mut self, ctx: &mut ViewContext<Self>) {
|
|
ctx.emit(PromptDisplayMenuEvent::CloseMenu);
|
|
ctx.notify();
|
|
}
|
|
|
|
fn should_show_environment_sidecar(&self) -> bool {
|
|
self.chip_menu_type == ChipMenuType::Environments
|
|
&& !self.is_footer_selected()
|
|
&& self.selected_index < self.filtered_items.len()
|
|
}
|
|
|
|
fn parse_sync_id_lossy(s: &str) -> SyncId {
|
|
if let Some(hashed) = ClientId::from_hash(s) {
|
|
SyncId::ClientId(hashed)
|
|
} else {
|
|
SyncId::ServerId(ServerId::from_string_lossy(s))
|
|
}
|
|
}
|
|
|
|
fn environment_sidecar_data(&self, app: &AppContext) -> Option<EnvironmentSidecarData> {
|
|
if !self.should_show_environment_sidecar() {
|
|
return None;
|
|
}
|
|
|
|
let item = self.filtered_items.get(self.selected_index)?.item.clone();
|
|
let sync_id = Self::parse_sync_id_lossy(&item.action_data());
|
|
let env = CloudAmbientAgentEnvironment::get_by_id(&sync_id, app)?;
|
|
|
|
let repo_names = env
|
|
.model()
|
|
.string_model
|
|
.github_repos
|
|
.iter()
|
|
.map(|repo| repo.repo.clone())
|
|
.collect::<Vec<_>>();
|
|
let repos_text = if repo_names.is_empty() {
|
|
"(none)".to_string()
|
|
} else {
|
|
repo_names.join(", ")
|
|
};
|
|
|
|
Some(EnvironmentSidecarData {
|
|
name: env.model().string_model.display_name(),
|
|
id: env.id.to_string(),
|
|
image: env.model().string_model.base_image.to_string(),
|
|
repos_text,
|
|
})
|
|
}
|
|
|
|
fn environment_sidecar_anchor_id(&self) -> Option<String> {
|
|
if !self.should_show_environment_sidecar() {
|
|
return None;
|
|
}
|
|
|
|
Some(format!("MenuPromptChip-{}", self.selected_index))
|
|
}
|
|
|
|
fn environment_sidecar_side(
|
|
&self,
|
|
position_id: &str,
|
|
app: &AppContext,
|
|
) -> EnvironmentSidecarSide {
|
|
let Some(window) = app.windows().platform_window(self.window_id) else {
|
|
return EnvironmentSidecarSide::Left;
|
|
};
|
|
|
|
// Anchor is the currently selected/hovered row.
|
|
let Some(anchor_rect) =
|
|
app.element_position_by_id_at_last_frame(self.window_id, position_id)
|
|
else {
|
|
return EnvironmentSidecarSide::Left;
|
|
};
|
|
|
|
let gap = ENV_SIDE_CAR_HORIZONTAL_GAP;
|
|
let window_width = window.size().x();
|
|
|
|
// If sidecar is on the right of the anchor.
|
|
let right_edge_if_on_right = anchor_rect.max_x() + gap + ENV_SIDE_CAR_WIDTH;
|
|
let overflow_right = (right_edge_if_on_right - window_width).max(0.);
|
|
|
|
// If sidecar is on the left of the anchor.
|
|
let left_edge_if_on_left = anchor_rect.min_x() - gap - ENV_SIDE_CAR_WIDTH;
|
|
let overflow_left = (0. - left_edge_if_on_left).max(0.);
|
|
|
|
let would_overflow_right = overflow_right > 0.;
|
|
let would_overflow_left = overflow_left > 0.;
|
|
|
|
match (would_overflow_left, would_overflow_right) {
|
|
(true, false) => EnvironmentSidecarSide::Right,
|
|
(false, true) => EnvironmentSidecarSide::Left,
|
|
(false, false) => EnvironmentSidecarSide::Left,
|
|
(true, true) => {
|
|
if overflow_left <= overflow_right {
|
|
EnvironmentSidecarSide::Left
|
|
} else {
|
|
EnvironmentSidecarSide::Right
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn environment_sidecar_positioning(
|
|
&self,
|
|
position_id: String,
|
|
app: &AppContext,
|
|
) -> Option<OffsetPositioning> {
|
|
// Ensure anchor rect exists in cache; otherwise positioning will be wrong.
|
|
app.element_position_by_id_at_last_frame(self.window_id, &position_id)?;
|
|
|
|
let side = self.environment_sidecar_side(&position_id, app);
|
|
let offset_y = -ENV_MENU_VERTICAL_PADDING;
|
|
|
|
Some(match side {
|
|
EnvironmentSidecarSide::Right => OffsetPositioning::offset_from_save_position_element(
|
|
position_id,
|
|
vec2f(ENV_SIDE_CAR_HORIZONTAL_GAP, offset_y),
|
|
PositionedElementOffsetBounds::WindowByPosition,
|
|
PositionedElementAnchor::TopRight,
|
|
ChildAnchor::TopLeft,
|
|
),
|
|
EnvironmentSidecarSide::Left => OffsetPositioning::offset_from_save_position_element(
|
|
position_id,
|
|
vec2f(-ENV_SIDE_CAR_HORIZONTAL_GAP, offset_y),
|
|
PositionedElementOffsetBounds::WindowByPosition,
|
|
PositionedElementAnchor::TopLeft,
|
|
ChildAnchor::TopRight,
|
|
),
|
|
})
|
|
}
|
|
|
|
fn environment_sidecar_overlay(
|
|
&self,
|
|
app: &AppContext,
|
|
) -> Option<(Box<dyn Element>, OffsetPositioning)> {
|
|
let data = self.environment_sidecar_data(app)?;
|
|
let position_id = self.environment_sidecar_anchor_id()?;
|
|
let positioning = self.environment_sidecar_positioning(position_id, app)?;
|
|
Some((self.render_environment_sidecar(&data, app), positioning))
|
|
}
|
|
|
|
fn render_environment_sidecar(
|
|
&self,
|
|
data: &EnvironmentSidecarData,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
let theme = appearance.theme();
|
|
|
|
let background = theme.surface_2();
|
|
let label_text_color = theme.sub_text_color(background).into_solid();
|
|
let main_text_color = theme.main_text_color(background).into_solid();
|
|
|
|
let label_font_size = 12.;
|
|
let value_font_size = 12.;
|
|
|
|
let id_key = format!("env-sidecar:{}:id", data.id);
|
|
let image_key = format!("env-sidecar:{}:image", data.id);
|
|
|
|
let icon = |icon: Icon| {
|
|
ConstrainedBox::new(icon.to_galaxyui_icon(Fill::Solid(label_text_color)).finish())
|
|
.with_width(ENV_SIDE_CAR_ICON_SIZE)
|
|
.with_height(ENV_SIDE_CAR_ICON_SIZE)
|
|
.finish()
|
|
};
|
|
|
|
let label_text = |text: &str| {
|
|
Text::new_inline(
|
|
text.to_string(),
|
|
appearance.ui_font_family(),
|
|
label_font_size,
|
|
)
|
|
.with_color(label_text_color)
|
|
.finish()
|
|
};
|
|
|
|
let value_text = |text: String| {
|
|
Text::new(text, appearance.ui_font_family(), value_font_size)
|
|
.with_color(main_text_color)
|
|
.with_selectable(true)
|
|
.finish()
|
|
};
|
|
|
|
let id_value = {
|
|
let env_id = data.id.clone();
|
|
render_copyable_text_field(
|
|
CopyableTextFieldConfig::new(env_id.clone())
|
|
.with_font_size(value_font_size)
|
|
.with_text_color(main_text_color)
|
|
.with_icon_size(ENV_SIDE_CAR_COPY_BUTTON_SIZE)
|
|
.with_mouse_state(self.env_sidecar_copy_id_mouse_state.clone())
|
|
.with_last_copied_at(self.env_sidecar_copy_feedback_times.get(&id_key))
|
|
.with_wrap_text(true)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_copy_button_placement(CopyButtonPlacement::NextToText),
|
|
move |ctx| {
|
|
ctx.dispatch_typed_action(DisplayChipMenuAction::CopyEnvironmentSidecarField {
|
|
key: id_key.clone(),
|
|
value: env_id.clone(),
|
|
});
|
|
},
|
|
app,
|
|
)
|
|
};
|
|
|
|
let image_value = {
|
|
let docker_image = data.image.clone();
|
|
render_copyable_text_field(
|
|
CopyableTextFieldConfig::new(docker_image.clone())
|
|
.with_font_size(value_font_size)
|
|
.with_text_color(main_text_color)
|
|
.with_icon_size(ENV_SIDE_CAR_COPY_BUTTON_SIZE)
|
|
.with_mouse_state(self.env_sidecar_copy_image_mouse_state.clone())
|
|
.with_last_copied_at(self.env_sidecar_copy_feedback_times.get(&image_key))
|
|
.with_wrap_text(true)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_copy_button_placement(CopyButtonPlacement::NextToText),
|
|
move |ctx| {
|
|
ctx.dispatch_typed_action(DisplayChipMenuAction::CopyEnvironmentSidecarField {
|
|
key: image_key.clone(),
|
|
value: docker_image.clone(),
|
|
});
|
|
},
|
|
app,
|
|
)
|
|
};
|
|
|
|
let row = |row_icon: Icon, label: &str, value: Box<dyn Element>, is_last: bool| {
|
|
let label_cluster = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(
|
|
Container::new(icon(row_icon))
|
|
.with_margin_right(ENV_SIDE_CAR_ICON_LABEL_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(label_text(label))
|
|
.finish();
|
|
|
|
let element = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Container::new(label_cluster)
|
|
.with_margin_right(ENV_SIDE_CAR_ROW_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(Shrinkable::new(1., value).finish())
|
|
.finish();
|
|
|
|
if is_last {
|
|
element
|
|
} else {
|
|
Container::new(element)
|
|
.with_margin_bottom(ENV_SIDE_CAR_ROW_GAP)
|
|
.finish()
|
|
}
|
|
};
|
|
|
|
let content = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(row(
|
|
Icon::Globe4,
|
|
"Name:",
|
|
value_text(data.name.clone()),
|
|
false,
|
|
))
|
|
.with_child(row(Icon::Hash, "ID:", id_value, false))
|
|
.with_child(row(Icon::Docker, "Image:", image_value, false))
|
|
.with_child(row(
|
|
Icon::Github,
|
|
"Repos:",
|
|
value_text(data.repos_text.clone()),
|
|
true,
|
|
))
|
|
.finish();
|
|
|
|
let scrollable_content = ClippedScrollable::vertical(
|
|
self.env_sidecar_scroll_state.clone(),
|
|
content,
|
|
ScrollbarWidth::Auto,
|
|
theme.nonactive_ui_detail().into(),
|
|
theme.active_ui_detail().into(),
|
|
// Leave the scrollbar gutter background transparent.
|
|
galaxyui::elements::Fill::None,
|
|
)
|
|
.with_padding_start(0.)
|
|
.with_padding_end(0.)
|
|
.with_overlayed_scrollbar()
|
|
.finish();
|
|
|
|
let inner = Container::new(scrollable_content)
|
|
.with_uniform_padding(ENV_SIDE_CAR_PADDING)
|
|
.with_border(
|
|
Border::all(1.).with_border_fill(Fill::Solid(internal_colors::neutral_2(theme))),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
|
ENV_SIDE_CAR_INNER_RADIUS,
|
|
)))
|
|
.finish();
|
|
|
|
let outer = Container::new(inner)
|
|
.with_background(background)
|
|
.with_border(
|
|
Border::all(1.).with_border_fill(Fill::Solid(internal_colors::neutral_4(theme))),
|
|
)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
|
ENV_SIDE_CAR_OUTER_RADIUS,
|
|
)))
|
|
.with_drop_shadow(Self::figma_menu_drop_shadow())
|
|
.finish();
|
|
|
|
ConstrainedBox::new(outer)
|
|
.with_width(ENV_SIDE_CAR_WIDTH)
|
|
.with_min_height(ENV_SIDE_CAR_HEIGHT)
|
|
.finish()
|
|
}
|
|
|
|
fn render_fixed_footer_option(
|
|
&self,
|
|
app: &AppContext,
|
|
footer_option: &FixedFooter,
|
|
) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
let theme = appearance.theme();
|
|
|
|
let chip_menu_type = self.chip_menu_type;
|
|
let (font_size, icon_size) = match chip_menu_type {
|
|
ChipMenuType::Environments => (ENV_MENU_ITEM_FONT_SIZE, ENV_MENU_ICON_SIZE),
|
|
ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => {
|
|
let font_size = appearance.ui_font_size();
|
|
(font_size, font_size * 0.8)
|
|
}
|
|
};
|
|
|
|
let item_horizontal_padding = self.menu_item_horizontal_padding();
|
|
let item_vertical_padding = self.menu_item_vertical_padding();
|
|
|
|
let is_footer_selected = self.is_footer_selected();
|
|
ConstrainedBox::new(
|
|
Hoverable::new(footer_option.mouse_state.clone(), move |mouse_state| {
|
|
let is_active = mouse_state.is_hovered() || is_footer_selected;
|
|
|
|
let background_color = if is_active {
|
|
match chip_menu_type {
|
|
ChipMenuType::Environments => Some(internal_colors::fg_overlay_4(theme)),
|
|
ChipMenuType::Directories
|
|
| ChipMenuType::Branches
|
|
| ChipMenuType::CodeReview => Some(theme.accent()),
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let text_color = if is_active {
|
|
match chip_menu_type {
|
|
ChipMenuType::Environments => {
|
|
theme.main_text_color(theme.surface_2()).into_solid()
|
|
}
|
|
ChipMenuType::Directories
|
|
| ChipMenuType::Branches
|
|
| ChipMenuType::CodeReview => {
|
|
theme.main_text_color(theme.accent()).into_solid()
|
|
}
|
|
}
|
|
} else {
|
|
theme.sub_text_color(theme.surface_2()).into_solid()
|
|
};
|
|
|
|
// Update icon and text colors based on hover state
|
|
let mut updated_text =
|
|
Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
|
|
|
// Add icon if it exists
|
|
if let Some(icon) = footer_option.action_item.icon(app) {
|
|
updated_text.add_child(
|
|
Container::new(
|
|
ConstrainedBox::new(
|
|
icon.to_galaxyui_icon(Fill::Solid(text_color)).finish(),
|
|
)
|
|
.with_height(icon_size)
|
|
.with_width(icon_size)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(8.)
|
|
.finish(),
|
|
);
|
|
} else {
|
|
// Add spacing equivalent to icon + margin for alignment
|
|
updated_text.add_child(
|
|
ConstrainedBox::new(Empty::new().finish())
|
|
.with_width(icon_size + 8.)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
// Add the text element
|
|
updated_text.add_child(
|
|
Text::new_inline(
|
|
footer_option.action_item.name(),
|
|
appearance.ui_font_family(),
|
|
font_size,
|
|
)
|
|
.autosize_text(MIN_FONT_SIZE)
|
|
.with_color(text_color)
|
|
.finish(),
|
|
);
|
|
|
|
let mut container = Container::new(updated_text.finish())
|
|
.with_horizontal_padding(item_horizontal_padding)
|
|
.with_vertical_padding(item_vertical_padding)
|
|
.with_border(Border::top(1.0));
|
|
|
|
if let Some(bg_color) = background_color {
|
|
container = container.with_background(bg_color);
|
|
}
|
|
|
|
container.finish()
|
|
})
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(DisplayChipMenuAction::SelectFixedFooterOption);
|
|
})
|
|
.finish(),
|
|
)
|
|
.with_width(self.menu_width())
|
|
.finish()
|
|
}
|
|
|
|
fn render_env_search_footer(
|
|
&self,
|
|
search_input: &ViewHandle<EditorView>,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let theme = Appearance::as_ref(app).theme();
|
|
let divider_color = internal_colors::fg_overlay_2(theme);
|
|
|
|
Container::new(ChildView::new(search_input).finish())
|
|
.with_margin_top(ENV_MENU_SEARCH_FOOTER_TOP_MARGIN)
|
|
.with_horizontal_padding(ENV_MENU_ITEM_HORIZONTAL_PADDING)
|
|
.with_padding_top(ENV_MENU_SEARCH_VERTICAL_PADDING)
|
|
.with_padding_bottom(ENV_MENU_SEARCH_BOTTOM_PADDING)
|
|
.with_border(Border::top(1.).with_border_fill(divider_color))
|
|
.finish()
|
|
}
|
|
|
|
fn render_items(&self, ctx: &AppContext) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(ctx);
|
|
let theme = appearance.theme();
|
|
if self.filtered_items.is_empty() {
|
|
// Show "No results" if search is active but no matches.
|
|
if !self.search_query.is_empty() {
|
|
// Match the model selector dropdown's no-results row exactly
|
|
// for the environments menu (label, font size, paddings, and
|
|
// text color); other chip menus keep their existing styling.
|
|
let (label, font_size, horizontal_padding, vertical_padding, text_color) =
|
|
match self.chip_menu_type {
|
|
ChipMenuType::Environments => (
|
|
"No results",
|
|
ENV_MENU_ITEM_FONT_SIZE,
|
|
ENV_MENU_ITEM_HORIZONTAL_PADDING,
|
|
ENV_MENU_ITEM_VERTICAL_PADDING,
|
|
internal_colors::text_sub(theme, theme.surface_2()),
|
|
),
|
|
ChipMenuType::Directories
|
|
| ChipMenuType::Branches
|
|
| ChipMenuType::CodeReview => (
|
|
"No results found",
|
|
appearance.ui_font_size(),
|
|
LABEL_HORIZONTAL_PADDING,
|
|
LABEL_VERTICAL_PADDING * 2.0,
|
|
theme.sub_text_color(theme.surface_2()).into_solid(),
|
|
),
|
|
};
|
|
return Container::new(
|
|
Text::new(label, appearance.ui_font_family(), font_size)
|
|
.with_color(text_color)
|
|
.finish(),
|
|
)
|
|
.with_horizontal_padding(horizontal_padding)
|
|
.with_vertical_padding(vertical_padding)
|
|
.finish();
|
|
}
|
|
return Empty::new().finish();
|
|
}
|
|
|
|
let selected_index = self.selected_index;
|
|
let filtered_items_length = self.filtered_items.len();
|
|
let filtered_items = self.filtered_items.clone();
|
|
let is_footer_hovered = self.is_footer_selected();
|
|
let menu_width = self.menu_width();
|
|
let item_horizontal_padding = self.menu_item_horizontal_padding();
|
|
let item_vertical_padding = self.menu_item_vertical_padding();
|
|
let chip_menu_type = self.chip_menu_type;
|
|
let list = UniformList::new(
|
|
self.list_state.clone(),
|
|
filtered_items.len(),
|
|
move |mut range, app| {
|
|
let appearance = Appearance::as_ref(app);
|
|
let theme = appearance.theme();
|
|
|
|
range.end = cmp::min(range.end, filtered_items.len());
|
|
range
|
|
.map(|index| {
|
|
let filtered_item = &filtered_items[index];
|
|
let item = &filtered_item.item;
|
|
let display_text_str = item.name();
|
|
|
|
let is_selected = index == selected_index && !is_footer_hovered;
|
|
|
|
let font_size = if matches!(chip_menu_type, ChipMenuType::Environments) {
|
|
ENV_MENU_ITEM_FONT_SIZE
|
|
} else {
|
|
appearance.ui_font_size()
|
|
};
|
|
let icon_size = font_size * 0.8; // Icon slightly smaller than text
|
|
|
|
let (main_text, selected_background) = match chip_menu_type {
|
|
ChipMenuType::Environments => (
|
|
theme.main_text_color(theme.surface_2()).into_solid(),
|
|
is_selected.then_some(internal_colors::fg_overlay_4(theme)),
|
|
),
|
|
ChipMenuType::Directories
|
|
| ChipMenuType::Branches
|
|
| ChipMenuType::CodeReview => {
|
|
if is_selected {
|
|
let bg = theme.accent();
|
|
(theme.main_text_color(bg).into_solid(), Some(bg))
|
|
} else {
|
|
(theme.main_text_color(theme.surface_2()).into_solid(), None)
|
|
}
|
|
}
|
|
};
|
|
|
|
// Create main container with SpaceBetween to float right elements to far right
|
|
let mut main_container = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_main_axis_size(MainAxisSize::Max);
|
|
|
|
// Create left side container with icon and main text
|
|
let mut left_side =
|
|
Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
|
|
|
let icon_gap = 8.;
|
|
if matches!(chip_menu_type, ChipMenuType::Environments) {
|
|
if let Some(icon) = item.icon(app) {
|
|
let icon_slot_size = ENV_MENU_ICON_SLOT_SIZE;
|
|
let glyph_size = ENV_MENU_ICON_SIZE;
|
|
|
|
let icon_glyph = ConstrainedBox::new(
|
|
icon.to_galaxyui_icon(Fill::Solid(main_text)).finish(),
|
|
)
|
|
.with_width(glyph_size)
|
|
.with_height(glyph_size)
|
|
.finish();
|
|
|
|
let icon_slot = ConstrainedBox::new(
|
|
Flex::row()
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(icon_glyph)
|
|
.finish(),
|
|
)
|
|
.with_width(icon_slot_size)
|
|
.with_height(icon_slot_size)
|
|
.finish();
|
|
|
|
left_side.add_child(
|
|
Container::new(icon_slot)
|
|
.with_margin_right(icon_gap)
|
|
.finish(),
|
|
);
|
|
}
|
|
} else if let Some(icon) = item.icon(app) {
|
|
left_side.add_child(
|
|
Container::new(
|
|
ConstrainedBox::new(
|
|
icon.to_galaxyui_icon(Fill::Solid(main_text)).finish(),
|
|
)
|
|
.with_height(icon_size)
|
|
.with_width(icon_size)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(icon_gap)
|
|
.finish(),
|
|
);
|
|
} else {
|
|
// Add spacing equivalent to icon + margin for alignment
|
|
left_side.add_child(
|
|
ConstrainedBox::new(Empty::new().finish())
|
|
.with_width(icon_size + icon_gap)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
// Create main text with highlighting if there's a match result
|
|
let display_text = if let Some(match_result) = &filtered_item.match_result {
|
|
Text::new_inline(
|
|
display_text_str,
|
|
appearance.ui_font_family(),
|
|
font_size,
|
|
)
|
|
.autosize_text(MIN_FONT_SIZE)
|
|
.with_color(main_text)
|
|
.with_single_highlight(
|
|
Highlight::new()
|
|
.with_properties(Properties::default().weight(Weight::Bold))
|
|
.with_foreground_color(main_text),
|
|
match_result.matched_indices.clone(),
|
|
)
|
|
} else {
|
|
Text::new_inline(
|
|
display_text_str,
|
|
appearance.ui_font_family(),
|
|
font_size,
|
|
)
|
|
.autosize_text(MIN_FONT_SIZE)
|
|
.with_color(main_text)
|
|
};
|
|
|
|
left_side.add_child(display_text.finish());
|
|
|
|
// Add left side to main container
|
|
main_container.add_child(left_side.finish());
|
|
|
|
// Add right-side element if available, using SpaceBetween alignment
|
|
if let Some(right_element) = item.right_side_element(app) {
|
|
main_container = main_container
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween);
|
|
main_container.add_child(right_element);
|
|
}
|
|
|
|
let mut container = Container::new(main_container.finish())
|
|
.with_horizontal_padding(item_horizontal_padding)
|
|
.with_vertical_padding(item_vertical_padding);
|
|
|
|
if !matches!(chip_menu_type, ChipMenuType::Environments)
|
|
&& (is_selected || index < filtered_items_length - 1)
|
|
{
|
|
container = container.with_border(Border::bottom(1.0));
|
|
}
|
|
|
|
if let Some(bg) = selected_background {
|
|
container = container.with_background(bg);
|
|
}
|
|
|
|
SavePosition::new(
|
|
EventHandler::new(container.finish())
|
|
.on_left_mouse_down(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(DisplayChipMenuAction::SelectItem {
|
|
index,
|
|
});
|
|
DispatchEventResult::StopPropagation
|
|
})
|
|
.on_mouse_in(
|
|
move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(DisplayChipMenuAction::Select {
|
|
index,
|
|
});
|
|
ctx.notify();
|
|
DispatchEventResult::StopPropagation
|
|
},
|
|
Some(MouseInBehavior {
|
|
fire_on_synthetic_events: false,
|
|
fire_when_covered: false,
|
|
}),
|
|
)
|
|
.finish(),
|
|
format!("MenuPromptChip-{index}").as_str(),
|
|
)
|
|
.finish()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
},
|
|
);
|
|
|
|
let (scrollbar_width, max_height, overlayed_scrollbar) = match self.chip_menu_type {
|
|
ChipMenuType::Environments => (
|
|
ScrollbarWidth::Auto,
|
|
ENV_MENU_MAX_HEIGHT - (ENV_MENU_VERTICAL_PADDING * 2.0),
|
|
true,
|
|
),
|
|
ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => {
|
|
(ScrollbarWidth::None, 200., false)
|
|
}
|
|
};
|
|
|
|
let mut scrollable = Scrollable::vertical(
|
|
self.scroll_state.clone(),
|
|
list.finish_scrollable(),
|
|
scrollbar_width,
|
|
theme.nonactive_ui_detail().into(),
|
|
theme.active_ui_detail().into(),
|
|
galaxyui::elements::Fill::None,
|
|
)
|
|
.with_padding_end(0.)
|
|
.with_padding_start(0.);
|
|
|
|
if overlayed_scrollbar {
|
|
scrollable = scrollable.with_overlayed_scrollbar();
|
|
}
|
|
|
|
// Return just the scrollable content area (no outer styling)
|
|
ConstrainedBox::new(scrollable.finish())
|
|
.with_width(menu_width)
|
|
.with_max_height(max_height)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl View for DisplayChipMenu {
|
|
fn ui_name() -> &'static str {
|
|
"DisplayMenu"
|
|
}
|
|
|
|
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
|
if focus_ctx.is_self_focused() {
|
|
if let Some(ref search_input) = self.search_input {
|
|
ctx.focus(search_input);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
let theme = appearance.theme();
|
|
|
|
// Create vertical flex container for main content + search input + sticky fixed footer option
|
|
let mut main_container = Flex::column();
|
|
|
|
let border_radius = Radius::Pixels(6.);
|
|
|
|
match self.chip_menu_type {
|
|
ChipMenuType::Environments => {
|
|
if !self.menu_items.is_empty() {
|
|
main_container.add_child(
|
|
Container::new(self.render_items(app))
|
|
.with_padding_top(self.menu_vertical_padding())
|
|
.with_padding_bottom(self.menu_vertical_padding())
|
|
.finish(),
|
|
);
|
|
}
|
|
if let Some(ref footer_option) = self.fixed_footer {
|
|
main_container.add_child(self.render_fixed_footer_option(app, footer_option));
|
|
}
|
|
if let Some(ref search_input_handle) = self.search_input {
|
|
main_container
|
|
.add_child(self.render_env_search_footer(search_input_handle, app));
|
|
}
|
|
}
|
|
ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => {
|
|
if let Some(ref search_input_handle) = self.search_input {
|
|
let search_input = appearance
|
|
.ui_builder()
|
|
.text_input(search_input_handle.clone())
|
|
.with_style(UiComponentStyles {
|
|
background: Some(Fill::Solid(ColorU::new(0, 0, 0, 0)).into()),
|
|
border_color: None,
|
|
border_width: Some(0.),
|
|
border_radius: None,
|
|
width: Some(self.menu_width() - (SEARCH_INPUT_HORIZONTAL_PADDING * 2.)),
|
|
padding: Some(Coords::uniform(4.)),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish();
|
|
|
|
let search_input_container = Container::new(search_input)
|
|
.with_horizontal_padding(SEARCH_INPUT_HORIZONTAL_PADDING)
|
|
.with_vertical_padding(2.)
|
|
.with_background(theme.surface_1())
|
|
.with_border(Border::all(1.0).with_border_color(theme.surface_2().into()))
|
|
.with_corner_radius(CornerRadius::with_top(border_radius))
|
|
.finish();
|
|
|
|
main_container.add_child(search_input_container);
|
|
}
|
|
if let Some(ref footer_option) = self.fixed_footer {
|
|
main_container.add_child(self.render_fixed_footer_option(app, footer_option));
|
|
}
|
|
if !self.menu_items.is_empty() {
|
|
main_container.add_child(
|
|
Container::new(self.render_items(app))
|
|
.with_padding_bottom(self.menu_vertical_padding())
|
|
.finish(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let menu_card = {
|
|
let menu_container = Container::new(main_container.finish())
|
|
.with_background(theme.surface_2())
|
|
.with_corner_radius(CornerRadius::with_all(border_radius));
|
|
|
|
let menu_container = match self.chip_menu_type {
|
|
ChipMenuType::Environments => menu_container
|
|
.with_border(
|
|
Border::all(1.)
|
|
.with_border_fill(Fill::Solid(internal_colors::neutral_4(theme))),
|
|
)
|
|
.with_drop_shadow(Self::figma_menu_drop_shadow()),
|
|
ChipMenuType::Directories | ChipMenuType::Branches | ChipMenuType::CodeReview => {
|
|
menu_container.with_drop_shadow(DropShadow::default())
|
|
}
|
|
};
|
|
|
|
ConstrainedBox::new(menu_container.finish())
|
|
.with_width(self.menu_width())
|
|
.finish()
|
|
};
|
|
|
|
let mut stack = Stack::new();
|
|
stack.add_child(menu_card);
|
|
|
|
if self.should_show_environment_sidecar() {
|
|
if let Some((sidecar, positioning)) = self.environment_sidecar_overlay(app) {
|
|
stack.add_positioned_overlay_child(sidecar, positioning);
|
|
}
|
|
}
|
|
|
|
Dismiss::new(stack.finish())
|
|
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(DisplayChipMenuAction::Close))
|
|
.prevent_interaction_with_other_elements()
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct GenericMenuEvent {
|
|
pub action_item: Arc<dyn GenericMenuItem>,
|
|
}
|
|
|
|
pub enum PromptDisplayMenuEvent {
|
|
MenuAction(GenericMenuEvent),
|
|
CloseMenu,
|
|
}
|
|
|
|
impl Entity for DisplayChipMenu {
|
|
type Event = PromptDisplayMenuEvent;
|
|
}
|
|
|
|
impl TypedActionView for DisplayChipMenu {
|
|
type Action = DisplayChipMenuAction;
|
|
|
|
fn handle_action(&mut self, action: &DisplayChipMenuAction, ctx: &mut ViewContext<Self>) {
|
|
match action {
|
|
DisplayChipMenuAction::SelectItem { index } => {
|
|
if *index >= self.filtered_items.len() {
|
|
return;
|
|
}
|
|
let item = self.filtered_items[*index].item.clone();
|
|
self.select_item(item, ctx)
|
|
}
|
|
DisplayChipMenuAction::Select { index } => self.select(*index, ctx),
|
|
DisplayChipMenuAction::SelectUp => self.select_prev(ctx),
|
|
DisplayChipMenuAction::SelectDown => self.select_next(ctx),
|
|
DisplayChipMenuAction::SelectEnter => self.select_enter(ctx),
|
|
DisplayChipMenuAction::SelectFixedFooterOption => self.select_fixed_footer_option(ctx),
|
|
DisplayChipMenuAction::CopyEnvironmentSidecarField { key, value } => {
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(value.clone()));
|
|
|
|
self.env_sidecar_copy_feedback_times
|
|
.insert(key.clone(), Instant::now());
|
|
|
|
let duration = COPY_FEEDBACK_DURATION;
|
|
ctx.spawn(
|
|
async move {
|
|
Timer::after(duration).await;
|
|
},
|
|
move |me, _, ctx| {
|
|
// Clean up old entries.
|
|
me.env_sidecar_copy_feedback_times
|
|
.retain(|_, time| time.elapsed() < duration);
|
|
ctx.notify();
|
|
},
|
|
);
|
|
|
|
ctx.notify();
|
|
}
|
|
DisplayChipMenuAction::Close => self.close(ctx),
|
|
}
|
|
}
|
|
}
|