diff --git a/rust-gui/src/controllers/launch.rs b/rust-gui/src/controllers/launch.rs index 8e9e0409..ce4330ac 100644 --- a/rust-gui/src/controllers/launch.rs +++ b/rust-gui/src/controllers/launch.rs @@ -148,7 +148,31 @@ impl View { let dx = screen.width() - SIZE.x; let dy = screen.height() - SIZE.y; let launch = rect(960.0 + dx, 636.0 + dy, 236.0, 60.0); + let response = ui.interact( + rect(launch.left(), launch.top(), 180.0, 60.0), + Id::new("launch"), + Sense::click(), + ); panel(ui, launch, 18, PRIMARY); + if data.editing_blocked || response.hovered() || response.is_pointer_button_down_on() { + let fill = if data.editing_blocked { + CONTROL + } else if response.is_pointer_button_down_on() { + egui::Color32::from_rgb(33, 61, 94) + } else { + egui::Color32::from_rgb(58, 99, 145) + }; + ui.painter().rect_filled( + response.rect, + egui::CornerRadius { + nw: 18, + sw: 18, + ne: 0, + se: 0, + }, + fill, + ); + } self.assets.icon( ui, "play", @@ -159,7 +183,7 @@ impl View { rect(launch.left() + 54.0, launch.top() + 7.0, 117.0, 28.0), "启动脚本", 17.0, - TEXT, + if data.editing_blocked { MUTED } else { TEXT }, ); label( ui, @@ -168,11 +192,11 @@ impl View { 10.0, MUTED, ); - let response = ui.interact( - rect(launch.left(), launch.top(), 180.0, 60.0), - Id::new("launch"), - Sense::click(), - ); + let response = if data.editing_blocked { + response + } else { + response.on_hover_cursor(egui::CursorIcon::PointingHand) + }; if response.on_hover_text("启动当前脚本").clicked() && !data.editing_blocked { if let Some(name) = data.selected { actions.push(Action::Request(Request { diff --git a/rust-gui/src/dialogs/common.rs b/rust-gui/src/dialogs/common.rs index 67c5905f..972ceb14 100644 --- a/rust-gui/src/dialogs/common.rs +++ b/rust-gui/src/dialogs/common.rs @@ -121,14 +121,22 @@ pub fn dialog_body(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) { .show(ui, contents); } +/// Give different pages of one dialog independent scroll state. +pub fn dialog_body_with_id(ui: &mut egui::Ui, id: &str, contents: impl FnOnce(&mut egui::Ui)) { + ui.push_id(id, |ui| dialog_body(ui, contents)); +} + /// Actions are ordered from the primary action at the right edge. pub fn dialog_footer(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) { ui.add_space(4.0); ui.separator(); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.set_min_height(34.0); - contents(ui); - }); + ui.allocate_ui_with_layout( + vec2(ui.available_width(), 34.0), + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + contents(ui); + }, + ); } pub fn dialog_status(ui: &mut egui::Ui, error: Option<&str>, notice: Option<&str>) { diff --git a/rust-gui/src/dialogs/config_dialog.rs b/rust-gui/src/dialogs/config_dialog.rs index 6d5ddc91..c86acf8c 100644 --- a/rust-gui/src/dialogs/config_dialog.rs +++ b/rust-gui/src/dialogs/config_dialog.rs @@ -82,10 +82,16 @@ impl SettingsDialog { } else { ("配置", "运行设置、配置备份与程序更新") }; + let body_id = match &self.daily_draft { + Some(draft) if draft.editing.is_some() => "daily-edit", + Some(_) => "daily-list", + None if self.run_draft.is_some() => "run-options", + None => "settings", + }; let close = crate::dialogs::common::Dialog::new("global-settings", title) .description(description) .show(ctx, !blocked, |ui| { - crate::dialogs::common::dialog_body(ui, |ui| { + crate::dialogs::common::dialog_body_with_id(ui, body_id, |ui| { ui.add_enabled_ui(!blocked && !self.needs_reload, |ui| { if let Some(draft) = &mut self.daily_draft { if let Some(request) = @@ -195,17 +201,21 @@ impl SettingsDialog { self.needs_reload.then_some("配置可能已保存,请刷新核对。"), ); crate::dialogs::common::dialog_footer(ui, |ui| { - if ui - .add_enabled( - !blocked - && !self.needs_reload - && self - .daily_draft - .as_ref() - .is_none_or(|draft| draft.supported && draft.editing.is_some()), - crate::dialogs::common::primary_button("保存"), - ) - .clicked() + let daily_list = self + .daily_draft + .as_ref() + .is_some_and(|draft| draft.editing.is_none()); + if !daily_list + && ui + .add_enabled( + !blocked + && !self.needs_reload + && self.daily_draft.as_ref().is_none_or(|draft| { + draft.supported && draft.editing.is_some() + }), + crate::dialogs::common::primary_button("保存"), + ) + .clicked() { if let Some(draft) = &self.daily_draft { action = draft.save_request().map(SettingsAction::Request); @@ -222,7 +232,14 @@ impl SettingsDialog { })); } if ui - .add_enabled(!blocked, crate::dialogs::common::secondary_button("取消")) + .add_enabled( + !blocked, + crate::dialogs::common::secondary_button(if daily_list { + "返回" + } else { + "取消" + }), + ) .clicked() { back = true; @@ -230,22 +247,32 @@ impl SettingsDialog { if blocked { ui.spinner(); } - ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - if ui - .add_enabled(!blocked, crate::dialogs::common::secondary_button("刷新")) - .clicked() - { - action = Some(SettingsAction::Request(Request { - method: if self.daily_draft.is_some() { - "plans.list" - } else { - "settings.view" - } - .into(), - params: json!({}), - })); - } - }); + let show_refresh = self.needs_reload + || self + .daily_draft + .as_ref() + .is_none_or(|draft| draft.editing.is_none()); + if show_refresh { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + if ui + .add_enabled( + !blocked, + crate::dialogs::common::secondary_button("刷新"), + ) + .clicked() + { + action = Some(SettingsAction::Request(Request { + method: if self.daily_draft.is_some() { + "plans.list" + } else { + "settings.view" + } + .into(), + params: json!({}), + })); + } + }); + } }); }); if !blocked && (back || close) { diff --git a/rust-gui/src/dialogs/daily_plan_dialog.rs b/rust-gui/src/dialogs/daily_plan_dialog.rs index c5f933c2..cced2cda 100644 --- a/rust-gui/src/dialogs/daily_plan_dialog.rs +++ b/rust-gui/src/dialogs/daily_plan_dialog.rs @@ -210,10 +210,14 @@ impl DailyView { } else { "已停用" }; - ui.label(format!( - "{} 每天 {} {}", - item.name, item.plan.target_time, status - )); + let summary = + format!("{} 每天 {} {}", item.name, item.plan.target_time, status); + // Reserve space for all actions, even when the plan name is long. + let label_width = (ui.available_width() + - 3.0 * (64.0 + ui.spacing().item_spacing.x)) + .max(0.0); + ui.add_sized([label_width, 30.0], egui::Label::new(&summary).truncate()) + .on_hover_text(&summary); if ui.button("编辑").clicked() { let mut plan = item.plan.clone(); plan.run_options.form_defaults(); diff --git a/rust-gui/tests/controllers/launch.rs b/rust-gui/tests/controllers/launch.rs index 687bb5a4..e129d8b1 100644 --- a/rust-gui/tests/controllers/launch.rs +++ b/rust-gui/tests/controllers/launch.rs @@ -88,6 +88,46 @@ fn unavailable_and_invalid_targets_fail_before_launch() { } use super::super::tests::{Scene, only_request}; + +#[test] +fn launch_hover_and_press_feedback_stays_within_launch_area() { + let mut scene = Scene::new(); + let launch = scene.ctx.read_response(Id::new("launch")).unwrap().rect; + let has_fill = |output: &egui::FullOutput, color| { + output.shapes.iter().any(|shape| { + matches!(&shape.shape, egui::Shape::Rect(rect) if rect.rect == launch && rect.fill == color) + }) + }; + let hover = egui::Color32::from_rgb(58, 99, 145); + let pressed = egui::Color32::from_rgb(33, 61, 94); + assert!(!has_fill(&scene.output, hover)); + scene.frame(vec![egui::Event::PointerMoved(launch.center())]); + assert!(has_fill(&scene.output, hover)); + scene.frame(vec![egui::Event::PointerButton { + pos: launch.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }]); + assert!(has_fill(&scene.output, pressed)); + scene.frame(vec![egui::Event::PointerButton { + pos: launch.center(), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + scene.editing_blocked = true; + scene.frame(vec![egui::Event::PointerMoved(launch.center())]); + assert!(!has_fill(&scene.output, hover)); + scene.editing_blocked = false; + let settings = scene + .ctx + .read_response(Id::new(("icon", "脚本配置"))) + .unwrap() + .rect; + scene.frame(vec![egui::Event::PointerMoved(settings.center())]); + assert!(!has_fill(&scene.output, hover)); +} #[test] fn script_settings_opens_edit_form_for_current_script() { let mut scene = Scene::new(); diff --git a/rust-gui/tests/dialogs/config_dialog.rs b/rust-gui/tests/dialogs/config_dialog.rs index 15fe2bd1..23674f53 100644 --- a/rust-gui/tests/dialogs/config_dialog.rs +++ b/rust-gui/tests/dialogs/config_dialog.rs @@ -1,8 +1,131 @@ use super::*; +use crate::dialogs::test_support::text_rect; fn data() -> SettingsView { serde_json::from_value(json!({"startup":{"enabled":true,"delay_seconds":60},"daily_enabled":false,"shutdown_supported":false,"run_options":{"shutdown_enabled":false,"shutdown_delay":0,"mute_enabled":false,"unmute_enabled":false,"close_running_enabled":false,"rerun_enabled":false,"notify_enabled":false,"email":"","smtp_host":"","smtp_port":"","auth_code":""}})).unwrap() } +#[test] +fn daily_list_footer_follows_content_without_empty_save() { + let ctx = egui::Context::default(); + theme::configure(&ctx); + let mut dialog = SettingsDialog::new(data()); + let plan = json!({"plans":[{"id":"default","name":"默认计划","plan":{"enabled":false,"target_time":"04:10","run_options":dialog.data.run_options},"state":null,"state_error":null}],"supported":true}); + dialog.set_daily(serde_json::from_value(plan).unwrap()); + let mut output = egui::FullOutput::default(); + for _ in 0..12 { + output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, theme::SIZE)), + ..Default::default() + }, + |ui| { + dialog.show(ui.ctx(), false); + }, + ); + output.textures_delta.clear(); + } + let row = text_rect(&output, "默认计划 每天 04:10 已停用"); + let back = text_rect(&output, "返回"); + assert!( + back.top() - row.bottom() < 100.0, + "footer too far below list: {row:?} {back:?}" + ); + assert!(!output.shapes.iter().any( + |shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == "保存") + )); + let modal = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Rect(rect) + if rect.fill == egui::Color32::from_rgb(17, 27, 42) + && rect.rect.contains(back.center()) => + { + Some(rect.rect) + } + _ => None, + }) + .min_by(|a, b| a.area().total_cmp(&b.area())) + .expect("dialog frame"); + assert!( + modal.bottom() - back.bottom() < 70.0, + "footer too far above bottom: {modal:?} {back:?}" + ); +} + +#[test] +fn daily_list_keeps_footer_visible_with_many_plans_on_small_screen() { + let ctx = egui::Context::default(); + theme::configure(&ctx); + let mut dialog = SettingsDialog::new(data()); + let plans: Vec<_> = (0..30).map(|i| json!({ + "id": format!("plan-{i}"), "name": format!("计划{i}"), + "plan": {"enabled":false,"target_time":"04:10","run_options":dialog.data.run_options}, + "state":null, "state_error":null + })).collect(); + dialog.set_daily(serde_json::from_value(json!({"plans":plans,"supported":true})).unwrap()); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 480.0)); + let mut output = egui::FullOutput::default(); + for _ in 0..12 { + output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ui| { + dialog.show(ui.ctx(), false); + }, + ); + output.textures_delta.clear(); + } + assert!(screen.contains_rect(text_rect(&output, "返回"))); + assert!(screen.contains_rect(text_rect(&output, "刷新"))); +} + +#[test] +fn daily_list_keeps_actions_visible_with_long_plan_name() { + let ctx = egui::Context::default(); + theme::configure(&ctx); + let mut dialog = SettingsDialog::new(data()); + let plan = json!({"plans":[{"id":"default","name":"非常长的计划名称".repeat(30),"plan":{"enabled":false,"target_time":"04:10","run_options":dialog.data.run_options},"state":null,"state_error":null}],"supported":true}); + dialog.set_daily(serde_json::from_value(plan).unwrap()); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 480.0)); + let mut output = egui::FullOutput::default(); + for _ in 0..12 { + output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ui| { + dialog.show(ui.ctx(), false); + }, + ); + output.textures_delta.clear(); + } + let back = text_rect(&output, "返回"); + let modal = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Rect(rect) + if rect.fill == egui::Color32::from_rgb(17, 27, 42) + && rect.rect.contains(back.center()) => + { + Some(rect.rect) + } + _ => None, + }) + .min_by(|a, b| a.area().total_cmp(&b.area())) + .expect("dialog frame"); + for label in ["编辑", "复制", "删除"] { + assert!( + modal.contains_rect(text_rect(&output, label)), + "{label} outside dialog" + ); + } +} + fn frame( ctx: &egui::Context, dialog: &mut SettingsDialog,