diff --git a/src/app/boot.rs b/src/app/boot.rs index b779aec..73cbb76 100644 --- a/src/app/boot.rs +++ b/src/app/boot.rs @@ -60,6 +60,7 @@ pub(crate) fn init() -> (AppState, Task) { let mut global_pre_request_script = String::new(); let mut global_test_script = String::new(); let mut sidebar_width = 200.0; + let mut ui_scale = 1.0; #[cfg(feature = "self-update")] let mut auto_check_updates = false; let mut tls_options = crate::services::http::TlsOptions::default(); @@ -69,6 +70,9 @@ pub(crate) fn init() -> (AppState, Task) { global_pre_request_script = sess.global_pre_request_script.clone(); global_test_script = sess.global_test_script.clone(); sidebar_width = sess.sidebar_width; + if sess.ui_scale >= 0.5 && sess.ui_scale <= 2.0 { + ui_scale = sess.ui_scale; + } #[cfg(feature = "self-update")] { auto_check_updates = sess.auto_check_updates; @@ -196,7 +200,7 @@ pub(crate) fn init() -> (AppState, Task) { panel_split: 50, sidebar_width, horizontal_layout: false, - ui_scale: 1.0, + ui_scale, #[cfg(feature = "self-update")] update: crate::app::UpdateState::Idle, #[cfg(feature = "self-update")] diff --git a/src/app/session.rs b/src/app/session.rs index cbce12e..455374b 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -16,6 +16,7 @@ pub(crate) fn persist_session(state: &mut AppState) { active_env_id: state.active_env().map(|e| e.id.clone()), sidebar_panel: format!("{:?}", state.sidebar.panel), sidebar_width: state.sidebar_width, + ui_scale: state.ui_scale, theme_idx: state.theme_idx, default_timeout_ms: state.default_timeout_ms, global_pre_request_script: state.global_pre_request_editor.text(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 640d1db..6c10811 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -92,9 +92,14 @@ fn dispatch(state: &mut AppState, message: Message) -> Task { fn handle_layout(state: &mut AppState, msg: LayoutMsg) -> Task { match msg { - LayoutMsg::ZoomIn => state.ui_scale = (state.ui_scale + 0.1).min(2.0), - LayoutMsg::ZoomOut => state.ui_scale = (state.ui_scale - 0.1).max(0.5), + LayoutMsg::ZoomIn => { + state.ui_scale = (((state.ui_scale + 0.1) * 10.0).round() / 10.0).min(2.0); + } + LayoutMsg::ZoomOut => { + state.ui_scale = (((state.ui_scale - 0.1) * 10.0).round() / 10.0).max(0.5); + } LayoutMsg::ZoomReset => state.ui_scale = 1.0, + LayoutMsg::SetZoom(scale) => state.ui_scale = scale.clamp(0.5, 2.0), LayoutMsg::PanelSplitChanged(split) => { if split >= 1 && split <= 99 { state.panel_split = split; diff --git a/src/message.rs b/src/message.rs index f113191..7d0d240 100644 --- a/src/message.rs +++ b/src/message.rs @@ -382,6 +382,7 @@ pub enum LayoutMsg { ZoomIn, ZoomOut, ZoomReset, + SetZoom(f64), PanelSplitChanged(u16), /// Drag on the sidebar/main-area splitter (issue #53): new sidebar width. SidebarWidthChanged(f32), diff --git a/src/state/session.rs b/src/state/session.rs index ea07387..9be99d2 100644 --- a/src/state/session.rs +++ b/src/state/session.rs @@ -12,6 +12,9 @@ pub struct AppSession { /// Sidebar content width in px (issue #53). #[serde(default = "default_sidebar_width")] pub sidebar_width: f32, + /// UI scale factor (scale_factor). + #[serde(default = "default_ui_scale")] + pub ui_scale: f64, #[serde(default)] pub theme_idx: usize, #[serde(default = "default_timeout_ms")] @@ -45,3 +48,28 @@ fn default_timeout_ms() -> u64 { fn default_sidebar_width() -> f32 { 200.0 } + +fn default_ui_scale() -> f64 { + 1.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_defaults_ui_scale_when_missing() { + let json = r#"{"tabs":[],"active_tab":0,"sidebar_panel":"collections"}"#; + let session: AppSession = serde_json::from_str(json).unwrap(); + assert_eq!(session.ui_scale, 1.0); + } + + #[test] + fn session_roundtrips_ui_scale() { + let mut session = AppSession::default(); + session.ui_scale = 1.25; + let json = serde_json::to_string(&session).unwrap(); + let loaded: AppSession = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.ui_scale, 1.25); + } +} diff --git a/src/ui/sidebar/settings.rs b/src/ui/sidebar/settings.rs index ea02dc8..68457d4 100644 --- a/src/ui/sidebar/settings.rs +++ b/src/ui/sidebar/settings.rs @@ -8,7 +8,7 @@ const LOGO_SVG: &[u8] = include_bytes!("../../../public/rustman-logo.svg"); #[cfg(feature = "self-update")] use crate::{app::UpdateState, message::UpdateMsg}; use crate::{ - message::{AppMsg, Message, SettingsMsg}, + message::{AppMsg, LayoutMsg, Message, SettingsMsg}, ui::theme::{Palette, TEXT_SM, THEMES, ThemeSpec}, ui::tree_label::{tree_label, tree_label_styled}, }; @@ -21,7 +21,11 @@ pub fn view(state: &crate::app::AppState) -> Element<'_, Message> { &state.git_user_email, )); let appearance = card(build_appearance(state.theme_idx)); - let layout = card(build_layout(state.horizontal_layout, state.sidebar_width)); + let layout = card(build_layout( + state.horizontal_layout, + state.ui_scale, + state.sidebar_width, + )); let request_defaults = card(build_request_defaults(&state.default_timeout_text)); let tls = card(build_tls(state.tls_options)); let global_scripts = card(build_global_scripts( @@ -376,7 +380,11 @@ fn swatch(color: Color) -> Element<'static, Message> { .into() } -fn build_layout(horizontal: bool, content_width: f32) -> Element<'static, Message> { +fn build_layout( + horizontal: bool, + ui_scale: f64, + content_width: f32, +) -> Element<'static, Message> { let options = [("Top / Bottom", false), ("Left / Right", true)]; let mut buttons = Vec::new(); @@ -426,7 +434,7 @@ fn build_layout(horizontal: bool, content_width: f32) -> Element<'static, Messag // Side-by-side needs ~95px per button ("Left / Right" ≈ 72px text + // padding); below that the labels wrap inside the buttons and the row // grows ragged. Stack vertically instead. - let mut col = column![section_label("LAYOUT")].spacing(6); + let mut col = column![section_label("LAYOUT"), field_label("Panels")].spacing(6); if content_width >= 230.0 { let mut row_el = row![].spacing(6); for b in buttons { @@ -438,6 +446,152 @@ fn build_layout(horizontal: bool, content_width: f32) -> Element<'static, Messag col = col.push(b); } } + + col = col.push(Space::new().height(4)); + col = col.push(field_label("UI Scale")); + + let pct = (ui_scale * 100.0).round() as i64; + let pct_label = format!("{pct}%"); + let is_default = (ui_scale - 1.0).abs() < 0.01; + + let btn_out = button( + text("−") + .size(TEXT_SM) + .color(Palette::text()) + .align_x(iced::alignment::Horizontal::Center), + ) + .on_press(Message::Layout(LayoutMsg::ZoomOut)) + .style(|_t, s| { + use iced::widget::button::Status; + iced::widget::button::Style { + background: Some(Background::Color(if matches!(s, Status::Hovered) { + Palette::hover() + } else { + Palette::surface_high() + })), + border: Border { + color: Palette::border_subtle(), + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .padding([4, 12]); + + let btn_reset = button( + text(pct_label) + .size(TEXT_SM) + .color(if is_default { + Palette::text() + } else { + Palette::accent() + }) + .align_x(iced::alignment::Horizontal::Center), + ) + .on_press(Message::Layout(LayoutMsg::ZoomReset)) + .style(move |_t, s| { + use iced::widget::button::Status; + iced::widget::button::Style { + background: Some(Background::Color(if !is_default { + Palette::accent_soft() + } else if matches!(s, Status::Hovered) { + Palette::hover() + } else { + Palette::surface_high() + })), + border: Border { + color: if !is_default { + Palette::accent() + } else { + Palette::border_subtle() + }, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .padding([4, 8]) + .width(Length::Fill); + + let btn_in = button( + text("+") + .size(TEXT_SM) + .color(Palette::text()) + .align_x(iced::alignment::Horizontal::Center), + ) + .on_press(Message::Layout(LayoutMsg::ZoomIn)) + .style(|_t, s| { + use iced::widget::button::Status; + iced::widget::button::Style { + background: Some(Background::Color(if matches!(s, Status::Hovered) { + Palette::hover() + } else { + Palette::surface_high() + })), + border: Border { + color: Palette::border_subtle(), + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }) + .padding([4, 12]); + + let zoom_row = row![btn_out, btn_reset, btn_in] + .spacing(6) + .align_y(iced::Alignment::Center); + + col = col.push(zoom_row); + + let presets = [0.8, 1.0, 1.25, 1.5]; + let mut preset_row = row![].spacing(4); + for preset in presets { + let is_selected = (ui_scale - preset).abs() < 0.01; + let p_label = format!("{:.0}%", preset * 100.0); + let btn = button( + text(p_label) + .size(10) + .font(crate::ui::theme::MONO) + .color(if is_selected { + Palette::accent() + } else { + Palette::text_muted() + }) + .align_x(iced::alignment::Horizontal::Center), + ) + .on_press(Message::Layout(LayoutMsg::SetZoom(preset))) + .style(move |_t, s| { + use iced::widget::button::Status; + iced::widget::button::Style { + background: Some(Background::Color(if is_selected { + Palette::accent_soft() + } else if matches!(s, Status::Hovered) { + Palette::hover() + } else { + Palette::surface_high() + })), + border: Border { + color: if is_selected { + Palette::accent() + } else { + Palette::border_subtle() + }, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } + }) + .padding([3, 4]) + .width(Length::Fill); + + preset_row = preset_row.push(btn); + } + col = col.push(preset_row); + col.into() } @@ -529,6 +683,8 @@ fn build_shortcuts(content_width: f32) -> Element<'static, Message> { (format!("{cmd}E"), "Export cURL"), (format!("{cmd}Enter"), "Send"), (format!("{alt}1-9"), "Switch tab"), + (format!("{cmd}+/-"), "Zoom in/out"), + (format!("{cmd}0"), "Reset zoom"), ("Esc".to_owned(), "Close dialog"), ("↑/↓".to_owned(), "Navigate"), ];