use galaxy_core::features::FeatureFlag; use galaxy_core::ui::external_product_icon::ExternalProductIcon; use galaxy_core::ui::icons::{Icon, ICON_DIMENSIONS}; use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::AnsiColorIdentifier; use galaxyui::accessibility::ActionAccessibilityContent; use galaxyui::elements::{ Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment, MainAxisSize, MouseState, MouseStateHandle, Padding, ParentElement, Radius, Text, Wrap, }; use galaxyui::fonts::Weight; use galaxyui::platform::Cursor; use galaxyui::ui_components::button::ButtonVariant; use galaxyui::ui_components::chip::Chip; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::ui_components::switch::SwitchStateHandle; use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext}; use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use pathfinder_color::ColorU; use crate::ai::mcp::templatable::CloudTemplatableMCPServer; use crate::ai::mcp::{MCPServerState, TemplatableMCPServerManager}; use crate::appearance::Appearance; use crate::cloud_object::{CloudObject, CloudObjectUuidLookup as _}; use crate::settings_view::mcp_servers::{style, ServerCardItemId}; use crate::ui_components::avatar::{Avatar, AvatarContent, StatusElementTypes}; use crate::ui_components::blended_colors; use crate::ui_components::buttons::icon_button; use crate::ui_components::red_notification_dot::RedNotificationDot; /// A chip displayed inline with the server card title, optionally with a leading icon. #[derive(Debug, Clone)] pub struct TitleChip { pub text: String, pub leading_icon: Option, } impl TitleChip { pub fn text(text: impl Into) -> Self { Self { text: text.into(), leading_icon: None, } } pub fn with_icon(text: impl Into, icon: Icon) -> Self { Self { text: text.into(), leading_icon: Some(icon), } } } #[derive(Debug, Clone)] pub enum ServerCardAction { ToggleToolsExpanded, ToggleRunningSwitch, Edit(ServerCardItemId), Share(ServerCardItemId), Install(ServerCardItemId), InstallServerUpdate(ServerCardItemId), ViewLogs(ServerCardItemId), LogOut(ServerCardItemId), FullCardClick, } #[derive(Debug, Clone)] pub enum ServerCardEvent { Edit(ServerCardItemId), Share(ServerCardItemId), ToggleRunningSwitch(ServerCardItemId, bool), Install(ServerCardItemId), InstallServerUpdate(ServerCardItemId), ViewLogs(ServerCardItemId), LogOut(ServerCardItemId), } #[derive(Default)] pub struct ServerCardMouseHandles { show_logs_icon_button: MouseStateHandle, logout_icon_button: MouseStateHandle, share_icon_button: MouseStateHandle, edit_icon_button: MouseStateHandle, update_icon_button: MouseStateHandle, view_logs_button: MouseStateHandle, edit_config_button: MouseStateHandle, setup_button: MouseStateHandle, tools_expandable_hover: MouseStateHandle, card_hover: MouseStateHandle, } pub enum StatusColor { Red, Yellow, Green, Neutral, } impl StatusColor { fn to_color(&self, appearance: &Appearance) -> ColorU { match self { StatusColor::Red => appearance.theme().ui_error_color(), StatusColor::Yellow => appearance.theme().ansi_fg_yellow(), StatusColor::Green => appearance.theme().ansi_fg_green(), StatusColor::Neutral => { blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()) } } } } pub struct StatusElement { indicator_type: StatusElementTypes, color: StatusColor, } #[derive(Default)] pub enum Background { #[default] Transparent, Filled, } #[derive(Default)] pub struct ServerCardOptions { pub show_view_logs_icon_button: bool, pub show_log_out_icon_button: bool, pub show_share_icon_button: bool, pub show_edit_config_icon_button: bool, pub show_update_available_icon_button: bool, pub show_view_logs_text_button: bool, pub show_edit_config_text_button: bool, pub show_setup_text_button: bool, pub show_add_icon: bool, pub full_card_clickable: bool, pub server_running_switch_state: Option, pub status_indicator: Option, pub status_line: Option, pub background: Background, } #[derive(PartialEq, Clone, Copy)] pub enum ServerCardStatus { AvailableToSave, SavedToDrive, Installed, StartingServer, Authenticating, Running, ShuttingDown, Error, } // TODO(aeybel): We'll want to get rid of this `from` function and the ServerCardStatus enum impl From for ServerCardStatus { fn from(state: MCPServerState) -> ServerCardStatus { match state { MCPServerState::NotRunning => ServerCardStatus::Installed, MCPServerState::Starting => ServerCardStatus::StartingServer, MCPServerState::Authenticating => ServerCardStatus::Authenticating, MCPServerState::Running => ServerCardStatus::Running, MCPServerState::ShuttingDown => ServerCardStatus::ShuttingDown, MCPServerState::FailedToStart => ServerCardStatus::Error, } } } impl From for ServerCardOptions { fn from(status: ServerCardStatus) -> ServerCardOptions { match status { ServerCardStatus::AvailableToSave => ServerCardOptions { show_view_logs_icon_button: false, show_log_out_icon_button: false, show_share_icon_button: false, show_edit_config_icon_button: false, show_update_available_icon_button: false, show_view_logs_text_button: false, show_edit_config_text_button: false, show_setup_text_button: false, show_add_icon: true, server_running_switch_state: None, status_indicator: None, status_line: None, background: Background::Transparent, full_card_clickable: true, }, ServerCardStatus::SavedToDrive => ServerCardOptions { show_view_logs_icon_button: false, show_log_out_icon_button: false, show_share_icon_button: false, show_edit_config_icon_button: true, show_update_available_icon_button: false, show_view_logs_text_button: false, show_edit_config_text_button: false, show_setup_text_button: true, show_add_icon: false, server_running_switch_state: None, status_indicator: None, status_line: None, background: Background::Filled, full_card_clickable: false, }, ServerCardStatus::Installed => ServerCardOptions { show_view_logs_icon_button: true, show_log_out_icon_button: false, show_share_icon_button: false, show_edit_config_icon_button: true, show_update_available_icon_button: false, show_view_logs_text_button: false, show_edit_config_text_button: false, show_setup_text_button: false, show_add_icon: false, server_running_switch_state: Some(false), status_indicator: Some(StatusElement { indicator_type: StatusElementTypes::Circle, color: StatusColor::Neutral, }), status_line: Some("Offline".to_string()), background: Background::Filled, full_card_clickable: false, }, ServerCardStatus::StartingServer => ServerCardOptions { show_view_logs_icon_button: true, show_log_out_icon_button: false, show_share_icon_button: false, show_edit_config_icon_button: true, show_update_available_icon_button: false, show_view_logs_text_button: false, show_edit_config_text_button: false, show_setup_text_button: false, show_add_icon: false, server_running_switch_state: Some(true), status_indicator: Some(StatusElement { indicator_type: StatusElementTypes::Circle, color: StatusColor::Yellow, }), status_line: Some("Starting server...".to_string()), background: Background::Filled, full_card_clickable: false, }, ServerCardStatus::Authenticating => ServerCardOptions { show_view_logs_icon_button: true, show_log_out_icon_button: false, show_share_icon_button: false, show_edit_config_icon_button: true, show_update_available_icon_button: false, show_view_logs_text_button: false, show_edit_config_text_button: false, show_setup_text_button: false, show_add_icon: false, server_running_switch_state: Some(true), status_indicator: Some(StatusElement { indicator_type: StatusElementTypes::Circle, color: StatusColor::Yellow, }), status_line: Some("Authenticating...".to_string()), background: Background::Filled, full_card_clickable: false, }, ServerCardStatus::Running => ServerCardOptions { show_view_logs_icon_button: true, show_log_out_icon_button: false, show_share_icon_button: false, show_edit_config_icon_button: true, show_update_available_icon_button: false, show_view_logs_text_button: false, show_edit_config_text_button: false, show_setup_text_button: false, show_add_icon: false, server_running_switch_state: Some(true), status_indicator: Some(StatusElement { indicator_type: StatusElementTypes::Circle, color: StatusColor::Green, }), status_line: None, background: Background::Filled, full_card_clickable: false, }, ServerCardStatus::ShuttingDown => ServerCardOptions { show_view_logs_icon_button: true, show_log_out_icon_button: false, show_share_icon_button: false, show_edit_config_icon_button: true, show_update_available_icon_button: false, show_view_logs_text_button: false, show_edit_config_text_button: false, show_setup_text_button: false, show_add_icon: false, server_running_switch_state: Some(false), status_indicator: Some(StatusElement { indicator_type: StatusElementTypes::Circle, color: StatusColor::Neutral, }), status_line: Some("Shutting down...".to_string()), background: Background::Filled, full_card_clickable: false, }, ServerCardStatus::Error => ServerCardOptions { show_view_logs_icon_button: false, show_log_out_icon_button: false, show_share_icon_button: false, show_edit_config_icon_button: false, show_update_available_icon_button: false, show_view_logs_text_button: true, show_edit_config_text_button: true, show_setup_text_button: false, show_add_icon: false, server_running_switch_state: Some(false), status_indicator: Some(StatusElement { indicator_type: StatusElementTypes::Icon(Icon::AlertTriangle), color: StatusColor::Red, }), status_line: None, background: Background::Filled, full_card_clickable: false, }, } } } pub struct ServerCardView { pub item_id: ServerCardItemId, title: String, description: Option, tools: Option>, error_text: Option, title_chips: Vec, render_options: ServerCardOptions, switch_state_handle: SwitchStateHandle, mouse_handles: ServerCardMouseHandles, is_tools_expanded: bool, } impl ServerCardView { pub fn new( item_id: ServerCardItemId, title: String, description: Option, tools: Option>, error_text: Option, title_chips: Vec, render_options: ServerCardOptions, ) -> Self { Self { item_id, title, description, tools, error_text, title_chips, render_options, switch_state_handle: Default::default(), mouse_handles: Default::default(), is_tools_expanded: false, } } pub fn title(&self) -> &str { &self.title } pub fn render_options(&self) -> &ServerCardOptions { &self.render_options } fn render_server_icon_and_status(&self, appearance: &Appearance) -> Box { // TODO(aeybel) will want to use gallery ids instead of title in the future // pending data model for the gallery items let product_icon = ExternalProductIcon::from_string(self.title.as_str()); let avatar_content = if let Some(icon) = product_icon { AvatarContent::ExternalProductIcon(icon) } else { AvatarContent::DisplayName(self.title.clone()) }; let mut avatar = Avatar::new( avatar_content, UiComponentStyles { width: Some(32.), height: Some(32.), border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))), font_family_id: Some(appearance.ui_font_family()), font_weight: Some(Weight::Bold), background: Some(appearance.theme().background().into()), font_size: Some(20.), font_color: Some(blended_colors::text_main( appearance.theme(), appearance.theme().background(), )), ..Default::default() }, ); if let Some(status_indicator) = &self.render_options.status_indicator { let status_indicator_circle_diameter: f32 = 8.; let status_indicator_icon_diameter: f32 = 10.; let status_style = match status_indicator.indicator_type { StatusElementTypes::Circle => UiComponentStyles { width: Some(status_indicator_circle_diameter), height: Some(status_indicator_circle_diameter), border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))), background: Some(Fill::Solid(status_indicator.color.to_color(appearance))), ..Default::default() }, StatusElementTypes::Icon(_) => UiComponentStyles { width: Some(status_indicator_icon_diameter), height: Some(status_indicator_icon_diameter), font_color: Some(status_indicator.color.to_color(appearance)), ..Default::default() }, }; avatar = avatar.with_status_element_with_offset( status_indicator.indicator_type.clone(), status_style, -5., 5., ); } avatar.build().finish() } fn render_tool_chips(tools: &[String], appearance: &Appearance) -> Vec> { tools .iter() .map(|tool| { Chip::new( tool.to_string(), UiComponentStyles { margin: Some(Coords { top: 0., bottom: 0., left: 0., right: 6., }), font_family_id: Some(appearance.ui_font_family()), font_size: Some(style::TOOL_CHIP_TEXT_SIZE), font_color: Some(blended_colors::text_main( appearance.theme(), internal_colors::neutral_4(appearance.theme()), )), background: Some(internal_colors::neutral_4(appearance.theme()).into()), border_radius: Some(CornerRadius::with_all(Radius::Pixels(5.))), ..Default::default() }, ) .build() .finish() }) .collect() } fn render_tools_expandable( &self, tools: &[String], appearance: &Appearance, ) -> Box { let text_color = blended_colors::text_sub(appearance.theme(), appearance.theme().background()); if tools.is_empty() { return Text::new( "No tools available".to_string(), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(text_color) .with_selectable(false) .finish(); } let chevron_icon = if self.is_tools_expanded { Icon::ChevronDown } else { Icon::ChevronRight }; let toggle_mouse_state = self.mouse_handles.tools_expandable_hover.clone(); let chevron_dimensions = 16.; Hoverable::new(toggle_mouse_state, move |_is_hovered| { Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Text::new( format!("{} tools available", tools.len()), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(text_color) .with_selectable(false) .finish(), ) .with_child( Container::new( ConstrainedBox::new( chevron_icon.to_galaxyui_icon(text_color.into()).finish(), ) .with_width(chevron_dimensions) .with_height(chevron_dimensions) .finish(), ) .with_margin_right(4.) .finish(), ) .finish() }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::ToggleToolsExpanded); }) .finish() } fn render_title_chip(chip: &TitleChip, appearance: &Appearance) -> Box { let chip_color = appearance .theme() .sub_text_color(appearance.theme().surface_3()) .into_solid(); let text_element = Text::new( chip.text.clone(), appearance.ui_font_family(), style::TITLE_CHIP_FONT_SIZE, ) .with_color(chip_color) .finish(); let inner: Box = if let Some(icon) = chip.leading_icon { Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(2.) .with_child( ConstrainedBox::new(icon.to_galaxyui_icon(chip_color.into()).finish()) .with_width(style::TITLE_CHIP_FONT_SIZE) .with_height(style::TITLE_CHIP_FONT_SIZE) .finish(), ) .with_child(text_element) .finish() } else { text_element }; Container::new(inner) .with_background(appearance.theme().surface_3()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.))) .with_horizontal_padding(3.) .with_vertical_padding(1.) .finish() } fn render_title_and_title_chip( title: String, title_chips: &[TitleChip], appearance: &Appearance, ) -> Box { let title = Text::new( title.clone(), appearance.ui_font_family(), appearance.ui_builder().ui_font_size(), ) .with_color(blended_colors::text_main( appearance.theme(), appearance.theme().surface_1(), )) .finish(); if title_chips.is_empty() { return title; } let mut wrap = Wrap::row() .with_spacing(style::SERVER_CARD_INTERIOR_SPACING) .with_run_spacing(style::SERVER_CARD_INTERIOR_SPACING) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(title); for chip in title_chips { wrap = wrap.with_child(Self::render_title_chip(chip, appearance)); } wrap.finish() } fn render_debug_lines(&self, app: &AppContext, appearance: &Appearance) -> Box { let mut lines = vec![format!("{}", self.item_id)]; match self.item_id { ServerCardItemId::TemplatableMCP(template_uuid) => { let cloud_server = CloudTemplatableMCPServer::get_by_uuid(&template_uuid, app); if let Some(cloud_server) = cloud_server { lines.push(format!("Template sync id: {}", cloud_server.sync_id())); } } ServerCardItemId::TemplatableMCPInstallation(installation_uuid) => { let installation = TemplatableMCPServerManager::as_ref(app) .get_installed_server(&installation_uuid); if let Some(installation) = installation { let template_uuid = installation.template_uuid(); let gallery_uuid = installation.gallery_uuid(); let gallery_uuid_text = match gallery_uuid { Some(uuid) => format!("Gallery Id: {uuid}"), None => "Gallery Id: None".to_string(), }; let cloud_server = CloudTemplatableMCPServer::get_by_uuid(&template_uuid, app); let template_sync_id_text = match cloud_server { Some(cloud_server) => { format!("Template sync id: {}", cloud_server.sync_id()) } None => "Could not find cloud template".to_string(), }; lines.push(format!( "{}", ServerCardItemId::TemplatableMCP(template_uuid) )); lines.push(gallery_uuid_text); lines.push(template_sync_id_text); } } ServerCardItemId::GalleryMCP(_) => {} ServerCardItemId::FileBasedMCP(_) => {} } FormattedTextElement::new( FormattedText::new( lines .into_iter() .map(|line| { FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(line)]) }) .collect::>(), ), appearance.ui_builder().ui_font_size(), appearance.ui_font_family(), appearance.ui_font_family(), blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()), HighlightedHyperlink::default(), ) .finish() } fn add_subtitle_lines( &self, mut info_column: Flex, appearance: &Appearance, app: &AppContext, ) -> Flex { if let Some(description) = &self.description { info_column = info_column.with_child( FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(vec![ FormattedTextFragment::plain_text(description.clone()), ])]), appearance.ui_builder().ui_font_size(), appearance.ui_font_family(), appearance.ui_font_family(), blended_colors::text_disabled( appearance.theme(), appearance.theme().surface_1(), ), HighlightedHyperlink::default(), ) .finish(), ); } if let Some(status_line) = &self.render_options.status_line { info_column = info_column.with_child( FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(vec![ FormattedTextFragment::plain_text(status_line.clone()), ])]), appearance.ui_builder().ui_font_size(), appearance.ui_font_family(), appearance.ui_font_family(), blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()), HighlightedHyperlink::default(), ) .finish(), ); } if let Some(error_text) = &self.error_text { info_column = info_column.with_child( FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(vec![ FormattedTextFragment::plain_text(error_text.clone()), ])]), appearance.ui_builder().ui_font_size(), appearance.ui_font_family(), appearance.ui_font_family(), appearance.theme().ui_error_color(), HighlightedHyperlink::default(), ) .finish(), ); } if FeatureFlag::McpDebuggingIds.is_enabled() { info_column = info_column.with_child(self.render_debug_lines(app, appearance)); } info_column } fn build_icon_button( &self, appearance: &Appearance, icon: Icon, tooltip_text: String, mouse_handle: MouseStateHandle, ) -> Hoverable { let ui_builder = appearance.ui_builder().clone(); icon_button(appearance, icon, false, mouse_handle) .with_tooltip(move || ui_builder.tool_tip(tooltip_text.clone()).build().finish()) .build() } fn render_actions_row(&self, state: &MouseState, appearance: &Appearance) -> Box { let item_id = self.item_id; let mut actions_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::End) .with_main_axis_size(MainAxisSize::Max) .with_spacing(style::SERVER_CARD_INTERIOR_SPACING); if state.is_hovered() { if self.render_options.show_view_logs_icon_button { actions_row = actions_row.with_child( self.build_icon_button( appearance, Icon::Code1, "Show logs".to_string(), self.mouse_handles.show_logs_icon_button.clone(), ) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::ViewLogs(item_id)) }) .finish(), ); } if self.render_options.show_log_out_icon_button { actions_row = actions_row.with_child( self.build_icon_button( appearance, Icon::LogOut, "Log out".to_string(), self.mouse_handles.logout_icon_button.clone(), ) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::LogOut(item_id)); }) .finish(), ); } if self.render_options.show_share_icon_button { actions_row = actions_row.with_child( self.build_icon_button( appearance, Icon::Share, "Share server".to_string(), self.mouse_handles.share_icon_button.clone(), ) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::Share(item_id)); }) .finish(), ); } if self.render_options.show_edit_config_icon_button { actions_row = actions_row.with_child( self.build_icon_button( appearance, Icon::Pencil, "Edit".to_string(), self.mouse_handles.edit_icon_button.clone(), ) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::Edit(item_id)); }) .finish(), ); } } if self.render_options.show_update_available_icon_button { actions_row = actions_row.with_child(self.render_update_available_icon(appearance)); } if self.render_options.show_view_logs_text_button { let view_logs_button = appearance .ui_builder() .button( ButtonVariant::Secondary, self.mouse_handles.view_logs_button.clone(), ) .with_centered_text_label("View logs".to_string()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::ViewLogs(item_id)) }) .finish(); actions_row = actions_row.with_child(view_logs_button); } if self.render_options.show_edit_config_text_button { let edit_config_button = appearance .ui_builder() .button( ButtonVariant::Accent, self.mouse_handles.edit_config_button.clone(), ) .with_centered_text_label("Edit config".to_string()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::Edit(item_id)); }) .finish(); actions_row = actions_row.with_child(edit_config_button); } if self.render_options.show_setup_text_button { let setup_button = appearance .ui_builder() .button( ButtonVariant::Accent, self.mouse_handles.setup_button.clone(), ) .with_centered_text_label("Set up".to_string()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::Install(item_id)); }) .finish(); actions_row = actions_row.with_child(setup_button); } if self.render_options.show_add_icon { let add_icon = galaxyui::elements::Icon::new( Icon::Plus.into(), blended_colors::text_main(appearance.theme(), appearance.theme().background()), ); actions_row = actions_row.with_child( ConstrainedBox::new(add_icon.finish()) .with_width(ICON_DIMENSIONS) .with_height(ICON_DIMENSIONS) .finish(), ); } if let Some(switch_state) = self.render_options.server_running_switch_state { let switch = appearance .ui_builder() .switch(self.switch_state_handle.clone()) .check(switch_state) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::ToggleRunningSwitch) }) .finish(); actions_row = actions_row.with_child(switch); } ConstrainedBox::new(actions_row.finish()) .with_min_height(ICON_DIMENSIONS) .with_width(self.get_actions_row_width()) .finish() } fn render_update_available_icon(&self, appearance: &Appearance) -> Box { let item_id = self.item_id; let update_available_button = self .build_icon_button( appearance, Icon::Refresh, "Server update available".to_string(), self.mouse_handles.update_icon_button.clone(), ) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(ServerCardAction::InstallServerUpdate(item_id)); }) .finish(); RedNotificationDot::render_with_offset( update_available_button, &UiComponentStyles { width: Some(style::UPDATE_AVAILABLE_DOT_WIDTH), height: Some(style::UPDATE_AVAILABLE_DOT_WIDTH), background: Some(Fill::Solid( AnsiColorIdentifier::Blue .to_ansi_color(&appearance.theme().terminal_colors().normal) .into(), )), ..RedNotificationDot::default_styles(appearance) }, (-4., 4.), ) } fn get_actions_row_width(&self) -> f32 { let mut number_of_buttons = 0; if self.render_options.show_add_icon { number_of_buttons += 1; } if self.render_options.show_view_logs_text_button { number_of_buttons += 1; } if self.render_options.show_edit_config_text_button { number_of_buttons += 1; } if number_of_buttons > 1 { style::SERVER_CARD_ACTIONS_WIDE_WIDTH } else { style::SERVER_CARD_ACTIONS_STANDARD_WIDTH } } } impl Entity for ServerCardView { type Event = ServerCardEvent; } impl TypedActionView for ServerCardView { type Action = ServerCardAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { ServerCardAction::ToggleToolsExpanded => { self.is_tools_expanded = !self.is_tools_expanded; ctx.notify(); } ServerCardAction::ToggleRunningSwitch => { if let Some(server_running_state) = self.render_options.server_running_switch_state { let new_state = !server_running_state; self.render_options.server_running_switch_state = Some(new_state); ctx.emit(ServerCardEvent::ToggleRunningSwitch( self.item_id, new_state, )); } else { log::error!("Server card: Tried to toggle a switch that does not exist.") } ctx.notify(); } ServerCardAction::Share(item_id) => { ctx.emit(ServerCardEvent::Share(*item_id)); ctx.notify(); } ServerCardAction::Edit(item_id) => { ctx.emit(ServerCardEvent::Edit(*item_id)); ctx.notify(); } ServerCardAction::Install(item_id) => { ctx.emit(ServerCardEvent::Install(*item_id)); ctx.notify(); } ServerCardAction::InstallServerUpdate(item_id) => { ctx.emit(ServerCardEvent::InstallServerUpdate(*item_id)); ctx.notify(); } ServerCardAction::ViewLogs(item_id) => { ctx.emit(ServerCardEvent::ViewLogs(*item_id)); ctx.notify(); } ServerCardAction::LogOut(item_id) => { ctx.emit(ServerCardEvent::LogOut(*item_id)); ctx.notify(); } ServerCardAction::FullCardClick => ctx.emit(ServerCardEvent::Install(self.item_id)), } } fn action_accessibility_contents( &mut self, _action: &Self::Action, _ctx: &mut ViewContext, ) -> ActionAccessibilityContent { ActionAccessibilityContent::default() } } impl View for ServerCardView { fn ui_name() -> &'static str { "ServerCardView" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let mut hoverable = Hoverable::new(self.mouse_handles.card_hover.clone(), |state| { let server_icon = self.render_server_icon_and_status(appearance); let title_and_title_chip = ServerCardView::render_title_and_title_chip( self.title.clone(), &self.title_chips, appearance, ); let mut info_column = Flex::column().with_child(title_and_title_chip); info_column = self.add_subtitle_lines(info_column, appearance, app); if let Some(tools) = &self.tools { let tools_info_row = self.render_tools_expandable(tools, appearance); info_column = info_column.with_child(tools_info_row) } let actions_row = self.render_actions_row(state, appearance); let mut card_body = Flex::column() .with_child( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Expanded::new(1., info_column.finish()).finish()) .with_child(actions_row) .finish(), ) .with_spacing(style::SERVER_CARD_INTERIOR_SPACING); if self.is_tools_expanded { if let Some(tools) = &self.tools { let tool_chips = ServerCardView::render_tool_chips(tools, appearance); let tool_chips_row = Wrap::row() .with_run_spacing(6.) .with_children(tool_chips) .finish(); card_body = card_body.with_child(tool_chips_row); } } let mut card = Container::new( Flex::row() .with_child(server_icon) .with_child(Expanded::new(1., card_body.finish()).finish()) .with_spacing(style::SERVER_CARD_INTERIOR_SPACING) .finish(), ) .with_padding(Padding::uniform(12.)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(style::CORNER_RADIUS))) .with_border(Border::all(1.).with_border_fill(theme.outline())); if self.render_options.full_card_clickable && (state.is_hovered() || state.is_clicked()) { card = card.with_background(theme.surface_3()); } else if matches!(self.render_options.background, Background::Filled) { card = card.with_background(theme.surface_1()); } card.finish() }); if self.render_options.full_card_clickable { hoverable = hoverable .on_click(|ctx, _, _| ctx.dispatch_typed_action(ServerCardAction::FullCardClick)); } hoverable.finish() } }