Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions rust-gui/src/controllers/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
16 changes: 12 additions & 4 deletions rust-gui/src/dialogs/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) {
Expand Down
85 changes: 56 additions & 29 deletions rust-gui/src/dialogs/config_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =
Expand Down Expand Up @@ -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);
Expand All @@ -222,30 +232,47 @@ 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;
}
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) {
Expand Down
12 changes: 8 additions & 4 deletions rust-gui/src/dialogs/daily_plan_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
40 changes: 40 additions & 0 deletions rust-gui/tests/controllers/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
123 changes: 123 additions & 0 deletions rust-gui/tests/dialogs/config_dialog.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading