//! Footer rendered at the bottom of the settings nav rail. //! //! The footer is the user's always-visible entrypoint into `settings.toml`. //! It takes one of three forms: //! * Hidden when the `SettingsFile` feature flag is disabled. //! * An inline yellow error alert (mirroring the workspace-level banner in //! `Workspace::render_settings_error_banner`) when the settings file has an //! error *and* the user has dismissed the workspace banner. //! * Otherwise, a plain bordered "Open settings file" button. use crate::appearance::Appearance; use crate::settings::SettingsFileError; use crate::ui_components::icons::Icon; use crate::WorkspaceAction; use pathfinder_color::ColorU; use warp_core::ui::color::coloru_with_opacity; use warp_core::ui::theme::Fill; use warpui::elements::{ Border, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Empty, Expanded, Flex, Highlight, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text, Wrap, }; use warpui::fonts::{FamilyId, Properties, Weight}; use warpui::platform::Cursor; /// Horizontal + vertical padding applied to the footer inside the sidebar. const FOOTER_PADDING: f32 = 12.; /// Font size used for the button label and the alert copy; matches the /// Figma spec for both designs. const FOOTER_FONT_SIZE: f32 = 12.; /// Height of the plain "Open settings file" button. const OPEN_BUTTON_HEIGHT: f32 = 32.; /// Height of action buttons inside the error alert. const ALERT_ACTION_BUTTON_HEIGHT: f32 = 24.; /// Size of the leading icons (search-sm, code-02, alert-circle, oz). const FOOTER_ICON_SIZE: f32 = 16.; /// Size of the Oz brand mark inside the "Fix with Oz" button. Matches the /// Figma spec and the workspace banner's secondary-button icon sizing. const ALERT_OZ_ICON_SIZE: f32 = 14.; /// Horizontal padding inside the "Open file" / "Fix with Oz" action buttons. /// Matches the workspace banner's secondary button pad. const ALERT_BUTTON_HORIZONTAL_PADDING: f32 = 8.; /// Spacing between the two action buttons when they fit on one row. const ALERT_BUTTON_SPACING: f32 = 4.; /// Maximum height of the scrollable text region inside the error alert. If /// the settings error's description exceeds this, the text scrolls within /// the alert so the footer doesn't balloon to fill the sidebar. The action /// buttons below the text always remain visible. const ALERT_TEXT_MAX_HEIGHT: f32 = 140.; /// Which variant of the footer should be shown. /// /// Extracted as a pure enum so the decision logic can be unit-tested without /// rendering a full `SettingsView`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsFooterKind { /// Footer is not rendered at all (feature flag off). Hidden, /// Render the plain "Open settings file" button. OpenButton, /// Render the inline yellow error alert. ErrorAlert, } impl SettingsFooterKind { /// Decide which footer variant to render based on the three inputs that /// gate the footer: the `SettingsFile` feature flag, the current settings /// file error (if any), and whether the user has dismissed the workspace /// banner for invalid settings. pub fn choose(feature_enabled: bool, has_error: bool, banner_dismissed: bool) -> Self { if !feature_enabled { Self::Hidden } else if has_error && banner_dismissed { Self::ErrorAlert } else { Self::OpenButton } } } /// Per-render-persistent handles for the footer. The `MouseStateHandle`s /// back the three clickable surfaces and must be created once and reused /// across renders per `WARP.md`; the scroll state handle serves the same /// purpose for the error alert's scrollable text region. #[derive(Clone, Default)] pub struct SettingsFooterMouseStates { pub open_settings_file_button: MouseStateHandle, pub alert_open_file_button: MouseStateHandle, pub alert_fix_with_oz_button: MouseStateHandle, /// Scroll state for the error alert's text region (heading + /// description), so scroll position survives renders. pub alert_text_scroll_state: ClippedScrollStateHandle, } /// Renders the plain "Open settings file" button shown in the default state. /// /// Visual spec (Figma `5655:62575`): 32px tall, full-width, 1px outlined, /// 4px rounded corners, `code-02` leading icon, semibold label. pub fn render_open_settings_file_button( appearance: &Appearance, mouse_state: MouseStateHandle, ) -> Box { let theme = appearance.theme(); let text_fill = theme.nonactive_ui_text_color(); let text_color = text_fill.into_solid(); let border_color = theme.outline().into_solid(); let ui_font_family = appearance.ui_font_family(); Hoverable::new(mouse_state, move |state| { let icon = ConstrainedBox::new(Icon::Code2.to_warpui_icon(text_fill).finish()) .with_width(FOOTER_ICON_SIZE) .with_height(FOOTER_ICON_SIZE) .finish(); let label = Text::new_inline("Open settings file", ui_font_family, FOOTER_FONT_SIZE) .with_color(text_color) .with_style(Properties { weight: Weight::Semibold, ..Default::default() }) .finish(); // Use `MainAxisSize::Max` so the row (and its surrounding bordered // container) expands to fill the full sidebar width. The icon + text // are then centered inside that full-width row. let row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_child(Container::new(icon).with_margin_right(4.).finish()) .with_child(label) .finish(); // Clip the row so the icon + label stay inside the bordered button // when the sidebar (which is `Shrinkable` inside the workspace row) // is resized narrower than the row's natural content width. let mut container = Container::new(Clipped::new(row).finish()) .with_border(Border::all(1.).with_border_color(border_color)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))); if state.is_hovered() { container = container.with_background_color(coloru_with_opacity(text_color, 10)); } ConstrainedBox::new(container.finish()) .with_height(OPEN_BUTTON_HEIGHT) .finish() }) .with_cursor(Cursor::PointingHand) .on_click(|ctx, _, _| ctx.dispatch_typed_action(WorkspaceAction::OpenSettingsFile)) .finish() } /// Renders the inline yellow alert shown when the settings file has an error /// and the workspace banner has been dismissed. Mirrors the workspace banner /// messaging and actions. pub fn render_settings_error_alert( appearance: &Appearance, error: &SettingsFileError, ai_enabled: bool, mouse_states: &SettingsFooterMouseStates, ) -> Box { let theme = appearance.theme(); // Warning banner colors: yellow background, contrast-safe text on top of // the yellow. Same pattern as `Workspace::render_workspace_banner`. let bg_color = theme.ansi_fg_yellow(); let text_color = theme.main_text_color(Fill::Solid(bg_color)).into_solid(); let ui_font_family = appearance.ui_font_family(); // ── Heading + description ──────────────────────────────────────────── // Copy is shared with `Workspace::render_settings_error_banner` via // `SettingsFileError::heading_and_description` so the two UIs can't // drift out of sync. let (heading, description) = error.heading_and_description(); let heading_char_count = heading.chars().count(); let combined_text = format!("{heading} {description}"); // Soft-wrap (the `Text::new` default) is appropriate here since the // alert's vertical space grows to fit the text. let mut text_widget = Text::new(combined_text, ui_font_family, FOOTER_FONT_SIZE).with_color(text_color); if heading_char_count > 0 { text_widget = text_widget.with_single_highlight( Highlight::new().with_properties(Properties::default().weight(Weight::Semibold)), (0..heading_char_count).collect(), ); } let alert_icon = ConstrainedBox::new( Icon::AlertCircle .to_warpui_icon(Fill::Solid(text_color)) .finish(), ) .with_width(FOOTER_ICON_SIZE) .with_height(FOOTER_ICON_SIZE) .finish(); let text_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_main_axis_size(MainAxisSize::Max) .with_child(Container::new(alert_icon).with_margin_right(8.).finish()) .with_child(Expanded::new(1., text_widget.finish()).finish()) .finish(); // Cap the text region's height so verbose settings errors don't balloon // the alert to fill the sidebar. Overflow scrolls within the alert; the // action buttons below stay fixed and always actionable. Scrollbar thumb // colors are derived from `text_color` (which already contrasts against // the yellow alert background) so they remain visible in both themes. // `ClippedScrollable` wants `warpui::elements::Fill` (not the theme // `Fill` used elsewhere in this file), so the three fills below are // fully qualified to avoid an import alias. let scrollable_text = ConstrainedBox::new( ClippedScrollable::vertical( mouse_states.alert_text_scroll_state.clone(), text_row, ScrollbarWidth::Auto, warpui::elements::Fill::Solid(coloru_with_opacity(text_color, 30)), warpui::elements::Fill::Solid(coloru_with_opacity(text_color, 60)), warpui::elements::Fill::None, ) .finish(), ) .with_max_height(ALERT_TEXT_MAX_HEIGHT) .finish(); // ── Action buttons ─────────────────────────────────────────────────── let open_file_button = render_alert_action_button( ui_font_family, text_color, mouse_states.alert_open_file_button.clone(), "Open file", /*icon=*/ None, /*bordered=*/ true, WorkspaceAction::OpenSettingsFile, ); // Use a `Wrap` flex as a graceful fallback: if the sidebar is narrower // than the buttons' combined natural width, they wrap onto a second // row instead of pushing the alert container wider than the sidebar. let mut buttons_row = Wrap::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min) .with_spacing(ALERT_BUTTON_SPACING) .with_run_spacing(ALERT_BUTTON_SPACING) .with_child(open_file_button); if ai_enabled { let error_description = error.to_string(); let fix_with_oz_button = render_alert_action_button( ui_font_family, text_color, mouse_states.alert_fix_with_oz_button.clone(), "Fix with Oz", Some(Icon::Oz), /*bordered=*/ false, WorkspaceAction::FixSettingsWithOz { error_description }, ); buttons_row.add_child(fix_with_oz_button); } // ── Assemble ───────────────────────────────────────────────────────── // Left-align the buttons with the start of the text (past the icon + gap). let buttons_indented = Container::new(buttons_row.finish()) .with_margin_left(FOOTER_ICON_SIZE + 8.) .with_margin_top(8.) .finish(); // `CrossAxisAlignment::Stretch` tightens each child's cross-axis (width) // constraint to the column's available width so the alert container // doesn't end up sized by buttons overflowing their allotment. let alert_body = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(scrollable_text) .with_child(buttons_indented) .finish(); Container::new(alert_body) .with_background_color(bg_color) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_uniform_padding(12.) .finish() } /// Wraps one of the three footer branches with the correct outer padding so /// it aligns with the nav items above. pub fn render_footer( kind: SettingsFooterKind, appearance: &Appearance, error: Option<&SettingsFileError>, ai_enabled: bool, mouse_states: &SettingsFooterMouseStates, ) -> Box { let inner: Box = match kind { SettingsFooterKind::Hidden => return Empty::new().finish(), SettingsFooterKind::OpenButton => render_open_settings_file_button( appearance, mouse_states.open_settings_file_button.clone(), ), SettingsFooterKind::ErrorAlert => match error { Some(error) => render_settings_error_alert(appearance, error, ai_enabled, mouse_states), // Defensive fallback: if the error disappears between `choose` and // `render_footer`, fall back to the plain button rather than // rendering an empty alert shell. None => render_open_settings_file_button( appearance, mouse_states.open_settings_file_button.clone(), ), }, }; Container::new(inner) .with_uniform_padding(FOOTER_PADDING) .finish() } /// Renders a single action button inside the inline alert. Mirrors /// `Workspace::render_banner_action_button` so the styling stays consistent /// with the workspace-level banner. fn render_alert_action_button( ui_font_family: FamilyId, text_color: ColorU, mouse_state: MouseStateHandle, text: &'static str, icon: Option, bordered: bool, action: WorkspaceAction, ) -> Box { Hoverable::new(mouse_state, move |state| { let mut row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min); if let Some(icon) = icon { row.add_child( Container::new( ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(text_color)).finish()) .with_width(ALERT_OZ_ICON_SIZE) .with_height(ALERT_OZ_ICON_SIZE) .finish(), ) .with_margin_right(4.) .finish(), ); } row.add_child( Text::new_inline(text.to_owned(), ui_font_family, FOOTER_FONT_SIZE) .with_color(text_color) .with_style(Properties { weight: Weight::Semibold, ..Default::default() }) .finish(), ); let mut container = Container::new(row.finish()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_horizontal_padding(ALERT_BUTTON_HORIZONTAL_PADDING); if bordered { container = container.with_border(Border::all(1.).with_border_color(text_color)); } if state.is_hovered() { container = container.with_background_color(coloru_with_opacity(text_color, 20)); } ConstrainedBox::new(container.finish()) .with_height(ALERT_ACTION_BUTTON_HEIGHT) .finish() }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone())) .finish() } #[cfg(test)] #[path = "settings_file_footer_tests.rs"] mod tests;