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, space: Space, item_states: ItemStates, overflow_button: Box, /// 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>, 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, item_states: ItemStates, space: Space, folder_depth: usize, menu: ViewHandle>, 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 { 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>, 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 { 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> { self.item.preview(appearance).map(|content_preview| { let mut stacked_preview_panels: Vec> = 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 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> { 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> { 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> { 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 { // 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 { 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 { 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 { Span::new( self.item .display_name() .unwrap_or_else(|| "Untitled".to_string()), style, ) .build() .finish() } pub fn render_item(&self, style: UiComponentStyles) -> Box { 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 { 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 = 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::() 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::() }) { 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::() }) { 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 } } }