feat(ui): add SettingsTab enum, InOverlayToast struct, and numeric input component
This commit is contained in:
parent
9058cc3f5d
commit
8caf54fbcf
1 changed files with 266 additions and 46 deletions
|
|
@ -8,9 +8,92 @@ use std::time::{Duration, Instant, SystemTime};
|
|||
use crate::config::ScytheConfig;
|
||||
use crate::ipc::{self, Command, DaemonStatus};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SettingsTab {
|
||||
#[default]
|
||||
Video,
|
||||
Audio,
|
||||
Shortcuts,
|
||||
ReplayAndStorage,
|
||||
Livestream,
|
||||
AppearanceAndSystem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InOverlayToast {
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub icon: crate::overlay::ToastIcon,
|
||||
pub created_at: Instant,
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
pub fn numeric_input_box(
|
||||
ui: &mut egui::Ui,
|
||||
value_str: &mut String,
|
||||
val: &mut u32,
|
||||
min: u32,
|
||||
max: u32,
|
||||
step: u32,
|
||||
unit: &str,
|
||||
accent: Color32,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
let dec_clicked = ui.add(
|
||||
egui::Button::new(egui::RichText::new("-").size(13.0).strong().color(Color32::WHITE))
|
||||
.fill(Color32::from_rgb(26, 32, 38))
|
||||
.stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 25)))
|
||||
.corner_radius(CornerRadius::same(3))
|
||||
.min_size(Vec2::new(28.0, 26.0)),
|
||||
).clicked();
|
||||
if dec_clicked {
|
||||
*val = val.saturating_sub(step).max(min);
|
||||
*value_str = val.to_string();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let edit_resp = ui.add(
|
||||
egui::TextEdit::singleline(value_str)
|
||||
.desired_width(70.0)
|
||||
.font(FontId::monospace(13.0))
|
||||
.text_color(Color32::WHITE)
|
||||
);
|
||||
if edit_resp.changed() {
|
||||
if let Ok(parsed) = value_str.trim().parse::<u32>() {
|
||||
*val = parsed.clamp(min, max);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if edit_resp.lost_focus() {
|
||||
*value_str = val.to_string();
|
||||
}
|
||||
|
||||
let inc_clicked = ui.add(
|
||||
egui::Button::new(egui::RichText::new("+").size(13.0).strong().color(Color32::WHITE))
|
||||
.fill(Color32::from_rgb(26, 32, 38))
|
||||
.stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 25)))
|
||||
.corner_radius(CornerRadius::same(3))
|
||||
.min_size(Vec2::new(28.0, 26.0)),
|
||||
).clicked();
|
||||
if inc_clicked {
|
||||
*val = (*val + step).min(max);
|
||||
*value_str = val.to_string();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if !unit.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(unit).size(12.0).color(accent));
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShadowPlayView {
|
||||
MainHud,
|
||||
Settings(SettingsTab),
|
||||
ReplaySettings,
|
||||
RecordSettings,
|
||||
StreamSettings,
|
||||
|
|
@ -368,6 +451,34 @@ fn pick_folder(current_dir: &str, tx: Sender<String>, is_active: Arc<AtomicBool>
|
|||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
use windows::Win32::System::Com::{CoInitializeEx, CoCreateInstance, COINIT_APARTMENTTHREADED, CLSCTX_INPROC_SERVER};
|
||||
use windows::Win32::UI::Shell::{FileOpenDialog, IFileOpenDialog, FOS_PICKFOLDERS, FOS_FORCEFILESYSTEM, SIGDN_FILESYSPATH};
|
||||
|
||||
let _ = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
|
||||
let mut picked = None;
|
||||
|
||||
if let Ok(dialog) = (unsafe { CoCreateInstance::<_, IFileOpenDialog>(&FileOpenDialog, None, CLSCTX_INPROC_SERVER) }) {
|
||||
unsafe {
|
||||
let _ = dialog.SetTitle(windows::core::w!("Select Recordings Directory"));
|
||||
if dialog.SetOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM).is_ok()
|
||||
&& dialog.Show(None).is_ok()
|
||||
&& let Ok(item) = dialog.GetResult()
|
||||
&& let Ok(name) = item.GetDisplayName(SIGDN_FILESYSPATH) {
|
||||
let s = name.to_string().unwrap_or_default();
|
||||
if !s.is_empty() {
|
||||
picked = Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sel) = picked {
|
||||
let _ = tx.send(sel);
|
||||
flag.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to PowerShell if COM dialog is unavailable
|
||||
let clean_cur = cur.replace('/', "\\").replace('\'', "''");
|
||||
let script = format!(
|
||||
"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; \
|
||||
|
|
@ -898,11 +1009,16 @@ fn render_dropdown_item(
|
|||
|
||||
// Section card helper matching GPU Screen Recorder Subsection design (bg_color: #191E22, bold white title)
|
||||
fn render_section_card(ui: &mut egui::Ui, header: &str, _accent: Color32, add_contents: impl FnOnce(&mut egui::Ui)) {
|
||||
let screen_h = ui.ctx().screen_rect().height();
|
||||
let pad_y = if screen_h < 850.0 { 8_i8 } else { 12_i8 };
|
||||
let pad_x = if screen_h < 850.0 { 12_i8 } else { 16_i8 };
|
||||
let header_spacing = if screen_h < 850.0 { 5.0 } else { 8.0 };
|
||||
|
||||
egui::Frame::NONE
|
||||
.fill(Color32::from_rgb(25, 30, 34))
|
||||
.stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 12)))
|
||||
.corner_radius(CornerRadius::ZERO)
|
||||
.inner_margin(Margin::symmetric(16_i8, 12_i8))
|
||||
.inner_margin(Margin::symmetric(pad_x, pad_y))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
if !header.is_empty() {
|
||||
|
|
@ -912,7 +1028,7 @@ fn render_section_card(ui: &mut egui::Ui, header: &str, _accent: Color32, add_co
|
|||
.strong()
|
||||
.color(Color32::WHITE),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.add_space(header_spacing);
|
||||
}
|
||||
add_contents(ui);
|
||||
});
|
||||
|
|
@ -969,6 +1085,8 @@ pub struct ScytheOverlayApp {
|
|||
pub settings_view_advanced: bool,
|
||||
last_content_h: f32,
|
||||
hud_notification: Option<HudNotification>,
|
||||
pub active_settings_tab: SettingsTab,
|
||||
pub in_overlay_toast: Option<InOverlayToast>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -1093,6 +1211,8 @@ impl ScytheOverlayApp {
|
|||
settings_view_advanced: false,
|
||||
last_content_h: 520.0,
|
||||
hud_notification: None,
|
||||
active_settings_tab: SettingsTab::Video,
|
||||
in_overlay_toast: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1282,6 +1402,7 @@ impl ScytheOverlayApp {
|
|||
// Center Title
|
||||
let title_text = match self.current_view {
|
||||
ShadowPlayView::MainHud => "Scythe",
|
||||
ShadowPlayView::Settings(_) => "Settings",
|
||||
ShadowPlayView::ReplaySettings => "Instant Replay",
|
||||
ShadowPlayView::RecordSettings => "Record",
|
||||
ShadowPlayView::StreamSettings => "Livestream",
|
||||
|
|
@ -1619,13 +1740,14 @@ impl ScytheOverlayApp {
|
|||
let screen_w = ui.available_width();
|
||||
let screen_h = ui.available_height();
|
||||
|
||||
// Exact GsrPage geometry formula from scratch/gsr-ui/src/gui/GsrPage.cpp:
|
||||
// content_page_size = (window_size * vec2(0.3333f, 0.70f)).floor();
|
||||
let content_w = (screen_w * 0.3333).clamp(580.0, 680.0);
|
||||
let max_content_h = (screen_h * 0.72).clamp(460.0, 780.0);
|
||||
let content_h = self.last_content_h.clamp(360.0, max_content_h);
|
||||
let spacing = (screen_w / 50.0).clamp(20.0, 38.0);
|
||||
let side_w = (screen_w / 10.0).clamp(160.0, 192.0);
|
||||
// GsrPage geometry dynamically adapts to screen size, scaling gracefully for compact handhelds / 720p displays:
|
||||
let is_compact_w = screen_w < 1400.0;
|
||||
let is_compact_h = screen_h < 850.0;
|
||||
let content_w = (screen_w * 0.3333).clamp(if is_compact_w { 520.0 } else { 580.0 }, 680.0);
|
||||
let max_content_h = (screen_h * if is_compact_h { 0.78 } else { 0.72 }).clamp(if is_compact_h { 380.0 } else { 460.0 }, 780.0);
|
||||
let content_h = self.last_content_h.clamp(320.0, max_content_h);
|
||||
let spacing = (screen_w / 50.0).clamp(if is_compact_w { 16.0 } else { 20.0 }, 38.0);
|
||||
let side_w = (screen_w / 10.0).clamp(if is_compact_w { 140.0 } else { 160.0 }, 192.0);
|
||||
|
||||
let content_x = ((screen_w - content_w) * 0.5).floor();
|
||||
let content_y = (48.0 + (screen_h - 48.0 - content_h) * 0.5).floor().max(52.0);
|
||||
|
|
@ -1641,6 +1763,7 @@ impl ScytheOverlayApp {
|
|||
let right_x = content_x + content_w + spacing;
|
||||
let btn_h = (screen_h / 15.0).clamp(52.0, 64.0);
|
||||
let btn_spacing = (screen_h * 0.015).clamp(12.0, 16.0);
|
||||
let def_btn_h = (btn_h * 0.58).clamp(32.0, 36.0);
|
||||
|
||||
// Entire freestanding region bounding box for outside-click dismiss
|
||||
self.panel_rect = egui::Rect::from_min_max(
|
||||
|
|
@ -1678,7 +1801,7 @@ impl ScytheOverlayApp {
|
|||
),
|
||||
};
|
||||
|
||||
// 2. RIGHT ELEMENT: Freestanding Button Stack ("Back", "Save", "Defaults")
|
||||
// 2. RIGHT ELEMENT: Freestanding Button Stack ("Back", "Save", compact "Reset to Defaults")
|
||||
// Allocate interactive rects first
|
||||
let back_rect = egui::Rect::from_min_size(egui::pos2(right_x, content_y), egui::vec2(side_w, btn_h));
|
||||
let back_resp = ui.allocate_rect(back_rect, egui::Sense::click());
|
||||
|
|
@ -1695,7 +1818,7 @@ impl ScytheOverlayApp {
|
|||
|
||||
let def_rect = egui::Rect::from_min_size(
|
||||
egui::pos2(right_x, content_y + (btn_h + btn_spacing) * 2.0_f32),
|
||||
egui::vec2(side_w, btn_h),
|
||||
egui::vec2(side_w, def_btn_h),
|
||||
);
|
||||
let def_resp = ui.allocate_rect(def_rect, egui::Sense::click());
|
||||
let def_hovered = def_resp.hovered();
|
||||
|
|
@ -1801,25 +1924,36 @@ impl ScytheOverlayApp {
|
|||
save_text_color,
|
||||
);
|
||||
|
||||
// Button 3: "Defaults" (GSR page_bg_color #262B2F, accent border on hover)
|
||||
// Button 3: Compact secondary "Reset to Defaults" button (subtle background, accent highlight on hover)
|
||||
painter.rect_filled(
|
||||
def_rect,
|
||||
CornerRadius::ZERO,
|
||||
if def_hovered {
|
||||
Color32::from_rgb(46, 52, 57)
|
||||
} else {
|
||||
Color32::from_rgb(38, 43, 47)
|
||||
Color32::from_rgb(32, 36, 40)
|
||||
},
|
||||
);
|
||||
if def_hovered {
|
||||
painter.rect_stroke(def_rect, CornerRadius::ZERO, Stroke::new(1.5_f32, accent), egui::StrokeKind::Inside);
|
||||
painter.rect_stroke(def_rect, CornerRadius::ZERO, Stroke::new(1.0_f32, accent), egui::StrokeKind::Inside);
|
||||
} else {
|
||||
painter.rect_stroke(
|
||||
def_rect,
|
||||
CornerRadius::ZERO,
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 18)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
painter.text(
|
||||
def_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"Defaults",
|
||||
FontId::proportional(14.0),
|
||||
Color32::from_rgb(190, 195, 200),
|
||||
"Reset to Defaults",
|
||||
FontId::proportional(12.5),
|
||||
if def_hovered {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
Color32::from_rgb(175, 180, 185)
|
||||
},
|
||||
);
|
||||
|
||||
// 3. CENTER ELEMENT: Content Box (#262B2F with top accent border)
|
||||
|
|
@ -2478,7 +2612,7 @@ impl ScytheOverlayApp {
|
|||
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if squared_button_sized(ui, "Reset Hotkeys to Defaults", false, accent, Some(Vec2::new(180.0, 28.0))) {
|
||||
if squared_button_sized(ui, "Reset to Defaults", false, accent, Some(Vec2::new(130.0, 26.0))) {
|
||||
let old_menu = self.config.menu_hotkey.clone();
|
||||
let old_save = self.config.save_hotkey.clone();
|
||||
let old_rec = self.config.record_hotkey.clone();
|
||||
|
|
@ -3603,6 +3737,7 @@ impl eframe::App for ScytheOverlayApp {
|
|||
self.render_top_bar(ctx, ui);
|
||||
match self.current_view {
|
||||
ShadowPlayView::MainHud => self.render_main_hud(ctx, ui),
|
||||
ShadowPlayView::Settings(_) => self.render_settings_view(ctx, ui),
|
||||
ShadowPlayView::ReplaySettings
|
||||
| ShadowPlayView::RecordSettings
|
||||
| ShadowPlayView::StreamSettings
|
||||
|
|
@ -3652,6 +3787,50 @@ pub fn apply_windows_transparency(title: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn query_focused_monitor_rect() -> (f32, f32, f32, f32) {
|
||||
unsafe {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetCursorPos};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
MonitorFromWindow, MonitorFromPoint, GetMonitorInfoW,
|
||||
MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||
};
|
||||
use windows::Win32::Foundation::POINT;
|
||||
|
||||
let hwnd = GetForegroundWindow();
|
||||
let hmon = if !hwnd.0.is_null() {
|
||||
MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
|
||||
} else {
|
||||
let mut pt = POINT::default();
|
||||
let _ = GetCursorPos(&mut pt);
|
||||
MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST)
|
||||
};
|
||||
|
||||
let mut mi = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if GetMonitorInfoW(hmon, &mut mi).as_bool() {
|
||||
let rect = mi.rcMonitor;
|
||||
let w = (rect.right - rect.left).abs() as f32;
|
||||
let h = (rect.bottom - rect.top).abs() as f32;
|
||||
let x = rect.left as f32;
|
||||
let y = rect.top as f32;
|
||||
if w > 320.0 && h > 200.0 {
|
||||
return (x, y, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN};
|
||||
let w = GetSystemMetrics(SM_CXSCREEN) as f32;
|
||||
let h = GetSystemMetrics(SM_CYSCREEN) as f32;
|
||||
let sw = if w > 100.0 { w } else { 1920.0 };
|
||||
let sh = if h > 100.0 { h } else { 1080.0 };
|
||||
(0.0, 0.0, sw, sh)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn query_focused_monitor_rect() -> (f32, f32, f32, f32) {
|
||||
// 1. hyprctl monitors -j (Hyprland/Wayland)
|
||||
|
|
@ -3704,16 +3883,6 @@ pub fn query_focused_monitor_rect() -> (f32, f32, f32, f32) {
|
|||
}
|
||||
|
||||
pub fn run_egui_overlay() {
|
||||
#[cfg(target_os = "windows")]
|
||||
let (screen_x, screen_y, screen_w, screen_h): (f32, f32, f32, f32) = unsafe {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN};
|
||||
let w = GetSystemMetrics(SM_CXSCREEN) as f32;
|
||||
let h = GetSystemMetrics(SM_CYSCREEN) as f32;
|
||||
let sw = if w > 100.0 { w } else { 1920.0 };
|
||||
let sh = if h > 100.0 { h } else { 1080.0 };
|
||||
(0.0, 0.0, sw, sh)
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let (screen_x, screen_y, screen_w, screen_h) = query_focused_monitor_rect();
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
|
|
@ -3836,35 +4005,49 @@ pub fn run_egui_toast(title: &str, subtitle: &str, icon: crate::overlay::ToastIc
|
|||
|
||||
let toast_pid_path = crate::ipc::get_toast_pid_path();
|
||||
if let Ok(prev_pid_str) = std::fs::read_to_string(&toast_pid_path)
|
||||
&& let Ok(prev_pid) = prev_pid_str.trim().parse::<u32>() {
|
||||
&& let Ok(prev_pid) = prev_pid_str.trim().parse::<u32>()
|
||||
&& prev_pid != std::process::id() as u32 {
|
||||
#[cfg(target_os = "windows")]
|
||||
unsafe {
|
||||
use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
|
||||
if let Ok(h) = OpenProcess(PROCESS_TERMINATE, false, prev_pid) {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
EnumWindows, GetWindowThreadProcessId, PostMessageW, WM_CLOSE,
|
||||
};
|
||||
use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, TerminateProcess, PROCESS_TERMINATE, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
|
||||
unsafe extern "system" fn close_toast(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
||||
let target = lparam.0 as u32;
|
||||
let mut pid = 0u32;
|
||||
GetWindowThreadProcessId(hwnd, Some(&mut pid));
|
||||
if pid == target {
|
||||
let _ = PostMessageW(hwnd, WM_CLOSE, None, None);
|
||||
}
|
||||
BOOL(1)
|
||||
}
|
||||
|
||||
let _ = EnumWindows(Some(close_toast), LPARAM(prev_pid as isize));
|
||||
if let Ok(h) = OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, prev_pid) {
|
||||
let _ = WaitForSingleObject(h, 100);
|
||||
let _ = TerminateProcess(h, 0);
|
||||
let _ = windows::Win32::Foundation::CloseHandle(h);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
unsafe {
|
||||
let _ = libc::kill(prev_pid as i32, libc::SIGTERM);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let _ = libc::kill(prev_pid as i32, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::create_dir_all(toast_pid_path.parent().unwrap_or(std::path::Path::new(".")));
|
||||
let _ = std::fs::write(&toast_pid_path, std::process::id().to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let (mon_x, screen_w): (f32, f32) = unsafe {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN};
|
||||
let w = GetSystemMetrics(SM_CXSCREEN) as f32;
|
||||
let sw = if w > 100.0 { w } else { 1920.0 };
|
||||
(0.0, sw)
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let (mon_x, _mon_y, screen_w, _screen_h) = query_focused_monitor_rect();
|
||||
let (mon_x, mon_y, screen_w, _screen_h) = query_focused_monitor_rect();
|
||||
|
||||
let pos_x = (mon_x + screen_w - toast_w).max(0.0);
|
||||
let pos_y = 16.0;
|
||||
let pos_y = mon_y + 16.0;
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
|
|
@ -3915,11 +4098,21 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_gsr_3_column_geometry_non_overlapping() {
|
||||
for (screen_w, screen_h) in [(1366.0_f32, 768.0_f32), (1920.0, 1080.0), (2560.0, 1440.0), (3840.0, 2160.0)] {
|
||||
let content_w = (screen_w * 0.3333).clamp(580.0, 680.0);
|
||||
let content_h = (screen_h * 0.70).clamp(520.0, 780.0);
|
||||
let spacing = (screen_w / 50.0).clamp(20.0, 38.0);
|
||||
let side_w = (screen_w / 10.0).clamp(160.0, 192.0);
|
||||
for (screen_w, screen_h) in [
|
||||
(1280.0_f32, 720.0_f32),
|
||||
(1280.0, 800.0),
|
||||
(1366.0, 768.0),
|
||||
(1920.0, 1080.0),
|
||||
(2560.0, 1440.0),
|
||||
(3840.0, 2160.0),
|
||||
] {
|
||||
let is_compact_w = screen_w < 1400.0;
|
||||
let is_compact_h = screen_h < 850.0;
|
||||
let content_w = (screen_w * 0.3333).clamp(if is_compact_w { 520.0 } else { 580.0 }, 680.0);
|
||||
let max_content_h = (screen_h * if is_compact_h { 0.78 } else { 0.72 }).clamp(if is_compact_h { 380.0 } else { 460.0 }, 780.0);
|
||||
let content_h = max_content_h;
|
||||
let spacing = (screen_w / 50.0).clamp(if is_compact_w { 16.0 } else { 20.0 }, 38.0);
|
||||
let side_w = (screen_w / 10.0).clamp(if is_compact_w { 140.0 } else { 160.0 }, 192.0);
|
||||
|
||||
let content_x = ((screen_w - content_w) * 0.5).floor();
|
||||
let content_y = (48.0 + (screen_h - 48.0 - content_h) * 0.5).floor().max(52.0);
|
||||
|
|
@ -3997,4 +4190,31 @@ mod tests {
|
|||
// When no update is available -> does not show
|
||||
assert!(!should_show_popup(false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_numeric_input_clamping_and_validation() {
|
||||
let mut val = 60u32;
|
||||
assert!((15..=360).contains(&val));
|
||||
|
||||
// Test valid text update
|
||||
let mut text = "144".to_string();
|
||||
if let Ok(parsed) = text.trim().parse::<u32>() {
|
||||
val = parsed.clamp(15, 360);
|
||||
}
|
||||
assert_eq!(val, 144);
|
||||
|
||||
// Test out of bounds clamping
|
||||
text = "999".to_string();
|
||||
if let Ok(parsed) = text.trim().parse::<u32>() {
|
||||
val = parsed.clamp(15, 360);
|
||||
}
|
||||
assert_eq!(val, 360);
|
||||
|
||||
// Test lower bounds clamping
|
||||
text = "2".to_string();
|
||||
if let Ok(parsed) = text.trim().parse::<u32>() {
|
||||
val = parsed.clamp(15, 360);
|
||||
}
|
||||
assert_eq!(val, 15);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue