931 lines
38 KiB
Rust
931 lines
38 KiB
Rust
use galaxyui::elements::{
|
|
AcceptedByDropTarget, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius,
|
|
CrossAxisAlignment, Draggable, DraggableState, DropShadow, Empty, Flex, Hoverable,
|
|
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
|
ParentOffsetBounds, Radius, SavePosition, Shrinkable, SizeConstraintCondition,
|
|
SizeConstraintSwitch, Stack,
|
|
};
|
|
use galaxyui::fonts::Weight;
|
|
use galaxyui::platform::Cursor;
|
|
use galaxyui::presenter::PositionCache;
|
|
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
|
use galaxyui::ui_components::text::Span;
|
|
use galaxyui::{AppContext, Element, SingletonEntity, ViewHandle};
|
|
use pathfinder_geometry::rect::RectF;
|
|
use pathfinder_geometry::vector::Vector2F;
|
|
|
|
use super::WarpDriveItemId;
|
|
use crate::appearance::Appearance;
|
|
use crate::cloud_object::model::persistence::CloudModel;
|
|
use crate::cloud_object::model::view::CloudViewModel;
|
|
use crate::cloud_object::{CloudObject, CloudObjectLocation, CloudObjectMetadataExt, Owner, Space};
|
|
use crate::drive::index::{
|
|
DriveIndexAction, AUTOSCROLL_DETECTION_DISTANCE, AUTOSCROLL_SPEED_MULTIPLIER,
|
|
DRIVE_INDEX_VIEW_POSITION_ID, FOLDER_DEPTH_INDENT, INDEX_CONTENT_MARGIN_LEFT, ITEM_FONT_SIZE,
|
|
ITEM_MARGIN_BOTTOM, ITEM_PADDING_HORIZONTAL, ITEM_PADDING_VERTICAL,
|
|
};
|
|
use crate::drive::items::WarpDriveItem;
|
|
use crate::drive::panel::WARP_DRIVE_POSITION_ID;
|
|
use crate::drive::CloudObjectTypeAndId;
|
|
use crate::menu::Menu;
|
|
use crate::ui_components::blended_colors;
|
|
use crate::ui_components::icons::{Icon, ICON_DIMENSIONS};
|
|
use crate::ui_components::menu_button::{
|
|
highlight_icon_button_with_context_menu_drive, icon_button_with_context_menu_drive,
|
|
MenuDirection,
|
|
};
|
|
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
|
|
use crate::workspace::tab_settings::TabSettings;
|
|
use crate::workspaces::user_profiles::UserProfiles;
|
|
use crate::workspaces::user_workspaces::UserWorkspaces;
|
|
|
|
pub(crate) fn tools_panel_menu_direction(app: &AppContext) -> MenuDirection {
|
|
let config = TabSettings::as_ref(app)
|
|
.header_toolbar_chip_selection
|
|
.clone();
|
|
if config
|
|
.left_items()
|
|
.contains(&HeaderToolbarItemKind::ToolsPanel)
|
|
{
|
|
MenuDirection::Right
|
|
} else {
|
|
MenuDirection::Left
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
pub struct ItemStates {
|
|
pub item_mouse_state: MouseStateHandle,
|
|
pub item_hover_state: MouseStateHandle,
|
|
pub menu_button_state: MouseStateHandle,
|
|
pub draggable_state: DraggableState,
|
|
pub item_sync_icon_hover_state: MouseStateHandle,
|
|
}
|
|
|
|
struct WarpDriveItemStyles {
|
|
// Height of each item
|
|
item_height: f32,
|
|
/// Default styles of the WarpDriveItem
|
|
default: UiComponentStyles,
|
|
/// On top of the default styles, active contains extra styling for when the item is being dragged
|
|
dragged: UiComponentStyles,
|
|
/// Similarly to active styles, hovered contains extra styling for a hovered item
|
|
hovered: UiComponentStyles,
|
|
}
|
|
|
|
impl WarpDriveItemStyles {
|
|
fn merge(self, style: UiComponentStyles) -> Self {
|
|
Self {
|
|
default: self.default.merge(style),
|
|
..self
|
|
}
|
|
}
|
|
|
|
fn default(appearance: &Appearance) -> WarpDriveItemStyles {
|
|
let theme = appearance.theme();
|
|
let item_height = ITEM_FONT_SIZE * 2.0 - ITEM_MARGIN_BOTTOM;
|
|
let background = theme.background();
|
|
WarpDriveItemStyles {
|
|
item_height,
|
|
default: UiComponentStyles::default()
|
|
.set_font_color(blended_colors::text_sub(theme, background))
|
|
.set_font_family_id(appearance.ui_builder().ui_font_family())
|
|
.set_font_size(ITEM_FONT_SIZE),
|
|
dragged: UiComponentStyles::default()
|
|
.set_font_family_id(appearance.ui_builder().ui_font_family())
|
|
.set_font_size(ITEM_FONT_SIZE)
|
|
.set_font_color(theme.foreground().into())
|
|
.set_background(
|
|
galaxy_core::ui::theme::color::internal_colors::fg_overlay_4(theme).into(),
|
|
)
|
|
.set_border_color(theme.accent().into()),
|
|
hovered: UiComponentStyles::default()
|
|
.set_font_family_id(appearance.ui_builder().ui_font_family())
|
|
.set_font_size(ITEM_FONT_SIZE)
|
|
.set_font_color(blended_colors::text_main(theme, background))
|
|
.set_background(
|
|
galaxy_core::ui::theme::color::internal_colors::fg_overlay_2(theme).into(),
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A UI wrapper around a row in warp drive that holds important UI state for the row and implements
|
|
/// a unified look for all rows in warp drive, like padding and hover states.
|
|
///
|
|
/// The item-specific information like icon, name, click_action, and preview modal are abstracted as much as
|
|
/// possible into the WarpDriveType enum.
|
|
pub struct WarpDriveRow<'a> {
|
|
item: Box<dyn WarpDriveItem>,
|
|
space: Space,
|
|
item_states: ItemStates,
|
|
overflow_button: Box<dyn Element>,
|
|
/// how many levels into a folder hierarchy the row is.
|
|
/// 0 means the object is in the root directory.
|
|
folder_depth: usize,
|
|
sync_icon: Option<Box<dyn Element>>,
|
|
can_move: bool,
|
|
styles: WarpDriveItemStyles,
|
|
menu_open: bool,
|
|
share_dialog_open: bool,
|
|
is_selected: bool,
|
|
is_focused: bool,
|
|
overflow_on_left: bool,
|
|
appearance: &'a Appearance,
|
|
}
|
|
|
|
impl<'a> WarpDriveRow<'a> {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
item: Box<dyn WarpDriveItem>,
|
|
item_states: ItemStates,
|
|
space: Space,
|
|
folder_depth: usize,
|
|
menu: ViewHandle<Menu<DriveIndexAction>>,
|
|
can_move: bool,
|
|
has_menu_items: bool,
|
|
menu_open: bool,
|
|
share_dialog_open: bool,
|
|
is_selected: bool,
|
|
is_focused: bool,
|
|
sync_queue_is_dequeueing: bool,
|
|
menu_direction: MenuDirection,
|
|
appearance: &'a Appearance,
|
|
) -> Option<Self> {
|
|
let warp_drive_item_id = item.warp_drive_id();
|
|
let overflow_button = match has_menu_items {
|
|
true => {
|
|
if is_focused || item_states.draggable_state.is_dragging() {
|
|
ConstrainedBox::new(
|
|
highlight_icon_button_with_context_menu_drive(
|
|
Icon::DotsVertical,
|
|
move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(
|
|
DriveIndexAction::ToggleItemOverflowMenu {
|
|
space,
|
|
warp_drive_item_id,
|
|
},
|
|
);
|
|
},
|
|
item_states.menu_button_state.clone(),
|
|
&menu,
|
|
menu_open,
|
|
menu_direction,
|
|
appearance,
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_width(20.)
|
|
.with_height(ICON_DIMENSIONS)
|
|
.finish()
|
|
} else {
|
|
ConstrainedBox::new(
|
|
icon_button_with_context_menu_drive(
|
|
Icon::DotsVertical,
|
|
move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(
|
|
DriveIndexAction::ToggleItemOverflowMenu {
|
|
space,
|
|
warp_drive_item_id,
|
|
},
|
|
);
|
|
},
|
|
item_states.menu_button_state.clone(),
|
|
&menu,
|
|
menu_open,
|
|
menu_direction,
|
|
None, /* cursor */
|
|
appearance,
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_width(20.)
|
|
.with_height(ICON_DIMENSIONS)
|
|
.finish()
|
|
}
|
|
}
|
|
false => Empty::new().finish(),
|
|
};
|
|
|
|
let sync_icon = item.sync_status_icon(
|
|
sync_queue_is_dequeueing,
|
|
item_states.item_sync_icon_hover_state.clone(),
|
|
appearance,
|
|
);
|
|
|
|
Some(Self {
|
|
item,
|
|
space,
|
|
item_states,
|
|
overflow_button,
|
|
folder_depth,
|
|
sync_icon,
|
|
can_move,
|
|
styles: WarpDriveItemStyles::default(appearance),
|
|
menu_open,
|
|
share_dialog_open,
|
|
is_selected,
|
|
is_focused,
|
|
overflow_on_left: matches!(menu_direction, MenuDirection::Left),
|
|
appearance,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new_from_cloud_object(
|
|
object: &dyn CloudObject,
|
|
item_states: ItemStates,
|
|
space: Space,
|
|
folder_depth: usize,
|
|
menu: ViewHandle<Menu<DriveIndexAction>>,
|
|
can_move: bool,
|
|
has_menu_items: bool,
|
|
menu_open: bool,
|
|
share_dialog_open: bool,
|
|
is_selected: bool,
|
|
is_focused: bool,
|
|
sync_queue_is_dequeueing: bool,
|
|
menu_direction: MenuDirection,
|
|
appearance: &'a Appearance,
|
|
) -> Option<Self> {
|
|
let item = object.to_warp_drive_item(appearance)?;
|
|
Self::new(
|
|
item,
|
|
item_states,
|
|
space,
|
|
folder_depth,
|
|
menu,
|
|
can_move,
|
|
has_menu_items,
|
|
menu_open,
|
|
share_dialog_open,
|
|
is_selected,
|
|
is_focused,
|
|
sync_queue_is_dequeueing,
|
|
menu_direction,
|
|
appearance,
|
|
)
|
|
}
|
|
|
|
pub fn should_show_preview(&self) -> bool {
|
|
self.item_states
|
|
.item_hover_state
|
|
.lock()
|
|
.expect("Should be able to lock")
|
|
.is_hovered()
|
|
&& !self
|
|
.item_states
|
|
.item_sync_icon_hover_state
|
|
.lock()
|
|
.expect("Should be able to lock")
|
|
.is_hovered()
|
|
}
|
|
|
|
/// Wraps an object preview in some uniformly-styled modal. Also conditionally returns an empty
|
|
/// view if there's limited horizontal space.
|
|
pub fn render_preview(
|
|
&self,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Option<Box<dyn Element>> {
|
|
self.item.preview(appearance).map(|content_preview| {
|
|
let mut stacked_preview_panels: Vec<Box<dyn Element>> =
|
|
vec![Container::new(content_preview)
|
|
.with_uniform_padding(16.)
|
|
.finish()];
|
|
|
|
stacked_preview_panels.extend(self.render_shared_object_owner(appearance, app));
|
|
|
|
// Tracks whether the object history rectangle is the bottommost preview panel, which determines if we
|
|
// need to render with rounded corners or not.
|
|
let countdown = self.render_object_deletion_countdown(appearance, app);
|
|
|
|
// If there's an object history preview, add this panel to our vector.
|
|
if let Some(object_history) =
|
|
self.render_object_history(appearance, countdown.is_none(), app)
|
|
{
|
|
// Insert above the permadeletion stat if it exists.
|
|
stacked_preview_panels.push(object_history);
|
|
}
|
|
|
|
// If there's a deletion stat, add this panel to our vector.
|
|
if let Some(countdown) = countdown {
|
|
stacked_preview_panels.push(
|
|
Container::new(Empty::new().finish())
|
|
.with_border(
|
|
Border::bottom(1.).with_border_fill(appearance.theme().outline()),
|
|
)
|
|
.finish(),
|
|
);
|
|
|
|
stacked_preview_panels.push(countdown);
|
|
}
|
|
|
|
// The full hover preview is the column containing all these sub-panels.
|
|
let full_hover_preview = Flex::column().with_children(stacked_preview_panels);
|
|
|
|
SizeConstraintSwitch::new(
|
|
ConstrainedBox::new(
|
|
Container::new(full_hover_preview.finish())
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
|
.with_background(appearance.theme().surface_2())
|
|
.with_border(
|
|
Border::all(1.).with_border_fill(appearance.theme().surface_3()),
|
|
)
|
|
.with_drop_shadow(DropShadow::default())
|
|
.finish(),
|
|
)
|
|
.with_max_width(400.)
|
|
.finish(),
|
|
vec![(
|
|
SizeConstraintCondition::WidthLessThan(180.),
|
|
Empty::new().finish(),
|
|
)],
|
|
)
|
|
.finish()
|
|
})
|
|
}
|
|
|
|
/// Returns a Box<dyn Element> representing a displayable view of this cloud object's history, including
|
|
/// for example the last metadata on edits.
|
|
fn render_object_history(
|
|
&self,
|
|
appearance: &Appearance,
|
|
with_rounded_bottom: bool,
|
|
app: &AppContext,
|
|
) -> Option<Box<dyn Element>> {
|
|
if let Some(metadata) = self.item.metadata() {
|
|
let editing_history = metadata.semantic_editing_history(app);
|
|
|
|
let action_history = self.item.action_summary(app);
|
|
|
|
let full_object_history_text = match (editing_history, action_history) {
|
|
(Some(edits), Some(actions)) => format!("{edits} | {actions}"),
|
|
(Some(edits), None) => edits,
|
|
_ => return None,
|
|
};
|
|
|
|
let history_text = appearance
|
|
.ui_builder()
|
|
.wrappable_text(full_object_history_text, false)
|
|
.with_style(UiComponentStyles {
|
|
font_family_id: Some(appearance.ui_font_family()),
|
|
font_color: Some(
|
|
appearance
|
|
.theme()
|
|
.sub_text_color(appearance.theme().surface_2())
|
|
.into(),
|
|
),
|
|
font_size: Some(12.),
|
|
font_weight: Some(Weight::Normal),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish();
|
|
|
|
// Render the element to span its parent horizontally
|
|
let text_spanning_parent = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_child(Shrinkable::new(1., history_text).finish())
|
|
.finish();
|
|
|
|
let container = Container::new(text_spanning_parent)
|
|
.with_background(appearance.theme().surface_1())
|
|
.with_padding_top(8.)
|
|
.with_padding_bottom(8.)
|
|
.with_padding_left(16.)
|
|
.with_padding_right(16.);
|
|
|
|
// Conditionally add the rounded bottom
|
|
Some(if with_rounded_bottom {
|
|
container
|
|
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
|
|
.finish()
|
|
} else {
|
|
container.finish()
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Render owner information, only for shared objects.
|
|
fn render_shared_object_owner(
|
|
&self,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Option<Box<dyn Element>> {
|
|
let WarpDriveItemId::Object(object_id) = self.item.warp_drive_id() else {
|
|
return None;
|
|
};
|
|
|
|
if CloudViewModel::as_ref(app).object_space(&object_id.uid(), app) != Some(Space::Shared) {
|
|
return None;
|
|
}
|
|
|
|
let owner = CloudModel::as_ref(app)
|
|
.get_by_uid(&object_id.uid())?
|
|
.permissions()
|
|
.owner;
|
|
|
|
let mut owner_label = "From ".to_string();
|
|
match owner {
|
|
Owner::User { user_uid } => {
|
|
match UserProfiles::as_ref(app).displayable_identifier_for_uid(user_uid) {
|
|
Some(user) => owner_label.push_str(&user),
|
|
None => owner_label.push_str("unknown user"),
|
|
}
|
|
}
|
|
Owner::Team { team_uid, .. } => owner_label.push_str(
|
|
UserWorkspaces::as_ref(app)
|
|
.team_from_uid(team_uid)
|
|
.map_or("unknown team", |team| &team.name),
|
|
),
|
|
}
|
|
|
|
let background = appearance.theme().surface_1();
|
|
let text_color = appearance.theme().sub_text_color(background);
|
|
|
|
let icon = Container::new(
|
|
ConstrainedBox::new(Icon::Users.to_galaxyui_icon(text_color).finish())
|
|
.with_height(15.)
|
|
.with_width(15.)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(6.)
|
|
.finish();
|
|
|
|
let owner_text = appearance
|
|
.ui_builder()
|
|
.wrappable_text(owner_label, false)
|
|
.with_style(UiComponentStyles {
|
|
font_color: Some(text_color.into()),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish();
|
|
|
|
Some(
|
|
Container::new(
|
|
Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(icon)
|
|
.with_child(Shrinkable::new(1., owner_text).finish())
|
|
.finish(),
|
|
)
|
|
.with_background(background)
|
|
.with_vertical_padding(8.)
|
|
.with_horizontal_padding(16.)
|
|
.finish(),
|
|
)
|
|
}
|
|
|
|
fn render_object_deletion_countdown(
|
|
&self,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Option<Box<dyn Element>> {
|
|
if let Some(metadata) = self.item.metadata() {
|
|
if let Some(countdown) = metadata.semantic_permadeletion_countdown(app) {
|
|
let icon_and_text = Container::new(
|
|
Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_child(
|
|
Container::new(
|
|
ConstrainedBox::new(
|
|
Icon::Clock
|
|
.to_galaxyui_icon(
|
|
appearance
|
|
.theme()
|
|
.sub_text_color(appearance.theme().surface_2()),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_height(15.)
|
|
.with_width(15.)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(6.)
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
Shrinkable::new(
|
|
1.,
|
|
appearance
|
|
.ui_builder()
|
|
.wrappable_text(countdown, false)
|
|
.with_style(UiComponentStyles {
|
|
font_family_id: Some(appearance.ui_font_family()),
|
|
font_color: Some(
|
|
appearance
|
|
.theme()
|
|
.sub_text_color(appearance.theme().surface_2())
|
|
.into(),
|
|
),
|
|
font_size: Some(12.),
|
|
font_weight: Some(Weight::Normal),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.finish(),
|
|
)
|
|
.finish();
|
|
|
|
Some(
|
|
Container::new(icon_and_text)
|
|
.with_background(appearance.theme().surface_1())
|
|
.with_padding_top(8.)
|
|
.with_padding_bottom(8.)
|
|
.with_padding_left(16.)
|
|
.with_padding_right(16.)
|
|
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
|
|
.finish(),
|
|
)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn render_chevron(&self, style: UiComponentStyles) -> Box<dyn Element> {
|
|
// Only render chevron for folders
|
|
if let Some(is_open) = self.item.is_folder_open() {
|
|
let chevron_icon = if is_open {
|
|
Icon::ChevronDown
|
|
} else {
|
|
Icon::ChevronRight
|
|
};
|
|
|
|
let icon_color = style.font_color.unwrap_or_else(|| {
|
|
blended_colors::text_sub(
|
|
self.appearance.theme(),
|
|
self.appearance.theme().background(),
|
|
)
|
|
});
|
|
|
|
Container::new(
|
|
ConstrainedBox::new(chevron_icon.to_galaxyui_icon(icon_color.into()).finish())
|
|
.with_width(16.)
|
|
.with_height(16.)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(4.)
|
|
.finish()
|
|
} else {
|
|
// Not a folder, render empty spacer to maintain alignment
|
|
Container::new(
|
|
ConstrainedBox::new(Empty::new().finish())
|
|
.with_width(16.)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(4.)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
fn render_icon(&self, style: UiComponentStyles) -> Box<dyn Element> {
|
|
let icon_to_render = match self.item.warp_drive_id() {
|
|
// This sets the icon color of folders correctly in color contrast cases, e.g. being dragged or focused
|
|
WarpDriveItemId::Object(CloudObjectTypeAndId::Folder(_))
|
|
if style == self.styles.dragged =>
|
|
{
|
|
self.item
|
|
.icon(self.appearance, Some(style.font_color.unwrap().into()))
|
|
}
|
|
_ => self.item.icon(self.appearance, None),
|
|
};
|
|
|
|
if let Some(icon) = icon_to_render {
|
|
Container::new(
|
|
ConstrainedBox::new(icon)
|
|
.with_width(16.)
|
|
.with_height(16.)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(8.)
|
|
.finish()
|
|
} else {
|
|
Empty::new().finish()
|
|
}
|
|
}
|
|
|
|
fn render_secondary_icon(&self, style: UiComponentStyles) -> Box<dyn Element> {
|
|
let icon_to_render = match self.item.warp_drive_id() {
|
|
WarpDriveItemId::Object(CloudObjectTypeAndId::Folder(_)) => self
|
|
.item
|
|
.secondary_icon(Some(style.font_color.unwrap().into())),
|
|
_ => self.item.secondary_icon(None),
|
|
};
|
|
|
|
if let Some(icon) = icon_to_render {
|
|
Container::new(
|
|
ConstrainedBox::new(icon)
|
|
.with_width(self.styles.default.font_size.unwrap_or_default())
|
|
.with_height(self.styles.default.font_size.unwrap_or_default())
|
|
.finish(),
|
|
)
|
|
.with_padding_left(2.)
|
|
.finish()
|
|
} else {
|
|
Empty::new().finish()
|
|
}
|
|
}
|
|
|
|
fn render_item_name(&self, style: UiComponentStyles) -> Box<dyn Element> {
|
|
Span::new(
|
|
self.item
|
|
.display_name()
|
|
.unwrap_or_else(|| "Untitled".to_string()),
|
|
style,
|
|
)
|
|
.build()
|
|
.finish()
|
|
}
|
|
|
|
pub fn render_item(&self, style: UiComponentStyles) -> Box<dyn Element> {
|
|
let chevron = self.render_chevron(style);
|
|
let icon = self.render_icon(style);
|
|
let name = self.render_item_name(style);
|
|
let secondary_icon = self.render_secondary_icon(style);
|
|
|
|
let item = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(Shrinkable::new(1., name).finish())
|
|
.with_child(secondary_icon)
|
|
.finish();
|
|
|
|
let action = self.item.click_action();
|
|
let space = self.space;
|
|
let warp_drive_item_id = self.item.warp_drive_id();
|
|
match warp_drive_item_id {
|
|
WarpDriveItemId::Object(_)
|
|
| WarpDriveItemId::AIFactCollection
|
|
| WarpDriveItemId::MCPServerCollection => {
|
|
Hoverable::new(self.item_states.item_mouse_state.clone(), move |_| {
|
|
Container::new(
|
|
Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(chevron)
|
|
.with_child(icon)
|
|
.with_child(Shrinkable::new(1., item).finish())
|
|
.finish(),
|
|
)
|
|
.with_margin_left(FOLDER_DEPTH_INDENT * self.folder_depth as f32)
|
|
.finish()
|
|
})
|
|
.on_click(move |ctx, _, _| {
|
|
if let Some(action) = action.clone() {
|
|
ctx.dispatch_typed_action(action);
|
|
}
|
|
})
|
|
.on_right_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(DriveIndexAction::ToggleItemOverflowMenu {
|
|
space,
|
|
warp_drive_item_id,
|
|
});
|
|
})
|
|
.finish()
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generate a callback for calculating the Drag bounds within Warp Drive
|
|
fn drag_bounds_callback() -> impl Fn(&PositionCache, Vector2F) -> Option<RectF> {
|
|
move |position_cache, window: Vector2F| {
|
|
let drive_index = position_cache.get_position(WARP_DRIVE_POSITION_ID)?;
|
|
|
|
let top_left = drive_index.origin();
|
|
|
|
Some(RectF::from_points(top_left, window))
|
|
}
|
|
}
|
|
|
|
impl UiComponent for WarpDriveRow<'_> {
|
|
type ElementType = SavePosition;
|
|
|
|
fn build(self) -> Self::ElementType {
|
|
let is_dragging = self.item_states.draggable_state.is_dragging();
|
|
let overflow_on_left = self.overflow_on_left;
|
|
|
|
// This is ONLY for rendering the font color correctly, which is set at the render_item level
|
|
let style = if is_dragging || self.is_focused {
|
|
self.styles.dragged
|
|
} else {
|
|
self.styles.default
|
|
};
|
|
let inner_item = self.render_item(style);
|
|
|
|
// Hoverable here doesn't have any action, it's mostly used for setting background styling based on
|
|
// the mouse state
|
|
let hoverable_item = Hoverable::new(
|
|
self.item_states.item_hover_state.clone(),
|
|
move |mouse_state| {
|
|
// If dragging or object has been focused, then theme accent background.
|
|
// If hovering / menu is open / object has been selected, then thick overlay background.
|
|
// If an object is both selected and focused, show focused background
|
|
let container_background_fill = if is_dragging || self.is_focused {
|
|
self.styles.dragged.background
|
|
} else if mouse_state.is_hovered()
|
|
|| self.menu_open
|
|
|| self.is_selected
|
|
|| self.share_dialog_open
|
|
{
|
|
self.styles.hovered.background
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let show_overflow = mouse_state.is_hovered() || self.menu_open;
|
|
|
|
let mut items_row = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
|
|
|
items_row.add_child(Shrinkable::new(1., inner_item).finish());
|
|
if let Some(sync_icon) = self.sync_icon {
|
|
items_row.add_child(Container::new(sync_icon).with_margin_right(4.).finish());
|
|
}
|
|
|
|
let row_element: Box<dyn Element> = if overflow_on_left {
|
|
let row_container = Container::new(items_row.finish())
|
|
.with_margin_left(INDEX_CONTENT_MARGIN_LEFT)
|
|
.with_padding_right(ITEM_PADDING_HORIZONTAL)
|
|
.with_padding_left(ITEM_PADDING_HORIZONTAL)
|
|
.with_padding_top(ITEM_PADDING_VERTICAL)
|
|
.with_padding_bottom(ITEM_PADDING_VERTICAL)
|
|
.finish();
|
|
if show_overflow {
|
|
let mut stack = Stack::new().with_child(row_container);
|
|
stack.add_positioned_child(
|
|
self.overflow_button,
|
|
OffsetPositioning::offset_from_parent(
|
|
pathfinder_geometry::vector::vec2f(0., 0.),
|
|
ParentOffsetBounds::ParentByPosition,
|
|
ParentAnchor::MiddleLeft,
|
|
ChildAnchor::MiddleLeft,
|
|
),
|
|
);
|
|
stack.finish()
|
|
} else {
|
|
row_container
|
|
}
|
|
} else {
|
|
if show_overflow {
|
|
items_row.add_child(self.overflow_button);
|
|
}
|
|
Container::new(items_row.finish())
|
|
.with_margin_left(INDEX_CONTENT_MARGIN_LEFT)
|
|
.with_padding_right(ITEM_PADDING_HORIZONTAL)
|
|
.with_padding_left(ITEM_PADDING_HORIZONTAL)
|
|
.with_padding_top(ITEM_PADDING_VERTICAL)
|
|
.with_padding_bottom(ITEM_PADDING_VERTICAL)
|
|
.finish()
|
|
};
|
|
|
|
let result_container = Container::new(
|
|
ConstrainedBox::new(row_element)
|
|
.with_height(self.styles.item_height)
|
|
.finish(),
|
|
)
|
|
.with_margin_bottom(ITEM_MARGIN_BOTTOM)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
|
|
|
|
match container_background_fill {
|
|
Some(background_fill) => {
|
|
result_container.with_background(background_fill).finish()
|
|
}
|
|
None => result_container.finish(),
|
|
}
|
|
},
|
|
)
|
|
.with_cursor(Cursor::PointingHand)
|
|
.finish();
|
|
|
|
match self.item.warp_drive_id() {
|
|
WarpDriveItemId::Object(item) => {
|
|
let save_position_child = match self.can_move {
|
|
true => {
|
|
Draggable::new(self.item_states.draggable_state, hoverable_item)
|
|
.with_drag_bounds_callback(drag_bounds_callback())
|
|
.with_accepted_by_drop_target_fn(move |drop_data, app| {
|
|
let Some(location) =
|
|
drop_data.as_any().downcast_ref::<CloudObjectLocation>()
|
|
else {
|
|
return AcceptedByDropTarget::No;
|
|
};
|
|
let cloud_model = CloudModel::handle(app);
|
|
if cloud_model.as_ref(app).can_move_object_to_location(
|
|
&item.uid(),
|
|
*location,
|
|
app,
|
|
) {
|
|
AcceptedByDropTarget::Yes
|
|
} else {
|
|
AcceptedByDropTarget::No
|
|
}
|
|
})
|
|
.on_drop(move |ctx, _, _, data| {
|
|
if let Some(location) = data.and_then(|data| {
|
|
data.as_any().downcast_ref::<CloudObjectLocation>()
|
|
}) {
|
|
ctx.dispatch_typed_action(DriveIndexAction::DropIndexItem {
|
|
cloud_object_type_and_id: item,
|
|
drop_target_location: *location,
|
|
});
|
|
}
|
|
})
|
|
.on_drag(move |ctx, _, dragged_item, data| {
|
|
// First, check if we are over a drop target for styling
|
|
if let Some(location) = data.and_then(|data| {
|
|
data.as_any().downcast_ref::<CloudObjectLocation>()
|
|
}) {
|
|
ctx.dispatch_typed_action(
|
|
DriveIndexAction::UpdateCurrentDropTarget {
|
|
drop_target_location: *location,
|
|
},
|
|
)
|
|
} else {
|
|
ctx.dispatch_typed_action(DriveIndexAction::ClearDropTarget)
|
|
}
|
|
|
|
// On a drag event, check to see if the index needs to be scrolled up or down
|
|
// to reveal new content.
|
|
if let Some(drive_index_view_position) =
|
|
ctx.element_position_by_id(DRIVE_INDEX_VIEW_POSITION_ID)
|
|
{
|
|
// First, check to see if we should scroll upwards (revealing more content at the top).
|
|
// This computes the distance between the top of the *currently-dragging* item and the top
|
|
// of the drive index view. If distance < 10, we emit a scroll event back to the index view.
|
|
let pixels_from_top =
|
|
dragged_item.min_y() - drive_index_view_position.min_y();
|
|
if pixels_from_top < AUTOSCROLL_DETECTION_DISTANCE {
|
|
// The speed of the autoscroll is a function of (1) how far away from the relevant border the object is
|
|
// and (2) what the speed multiplier is.
|
|
// Note: Scrolling upwards is decreasing the scroll value, so we multiply by -1 in this case.
|
|
let scroll_speed = ((AUTOSCROLL_DETECTION_DISTANCE
|
|
- pixels_from_top)
|
|
/ AUTOSCROLL_DETECTION_DISTANCE)
|
|
* AUTOSCROLL_SPEED_MULTIPLIER;
|
|
ctx.dispatch_typed_action(DriveIndexAction::Autoscroll {
|
|
delta: -scroll_speed,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Otherwise, check to see if we should scroll downwards (revealing more content at the
|
|
// bottom).
|
|
// This computes the distance between the bottom of the *currently-dragging* item
|
|
// and the bottom of the drive index view. If distance < 10, emit a scroll event.
|
|
let pixels_from_bottom =
|
|
drive_index_view_position.max_y() - dragged_item.max_y();
|
|
if pixels_from_bottom < AUTOSCROLL_DETECTION_DISTANCE {
|
|
// See comment above about determining the speed of the scroll.
|
|
let scroll_speed = ((AUTOSCROLL_DETECTION_DISTANCE
|
|
- pixels_from_bottom)
|
|
/ AUTOSCROLL_DETECTION_DISTANCE)
|
|
* AUTOSCROLL_SPEED_MULTIPLIER;
|
|
ctx.dispatch_typed_action(DriveIndexAction::Autoscroll {
|
|
delta: scroll_speed,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
.finish()
|
|
}
|
|
false => hoverable_item,
|
|
};
|
|
SavePosition::new(
|
|
save_position_child,
|
|
&self.item.warp_drive_id().drive_row_position_id(),
|
|
)
|
|
}
|
|
WarpDriveItemId::AIFactCollection | WarpDriveItemId::MCPServerCollection => {
|
|
SavePosition::new(
|
|
hoverable_item,
|
|
&self.item.warp_drive_id().drive_row_position_id(),
|
|
)
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
fn with_style(self, style: UiComponentStyles) -> Self {
|
|
Self {
|
|
styles: self.styles.merge(style),
|
|
..self
|
|
}
|
|
}
|
|
}
|