Files
galaxy/app/src/settings_view/settings_file_footer.rs
T

376 lines
16 KiB
Rust

//! 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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
let inner: Box<dyn Element> = 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<Icon>,
bordered: bool,
action: WorkspaceAction,
) -> Box<dyn Element> {
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;