diff --git a/src/ui.rs b/src/ui.rs index 1079e43..db69b7b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,16 +10,19 @@ use controls::{ }; use nwd::NwgUi; +use nwg::ControlHandle; use nwg::NativeUi; use std::cell::RefCell; +use std::ffi::c_void; use std::rc::Rc; use crate::{config::model::OptionGroupType, context::AppContext, save::saver::SaveSaver}; use windows_sys::Win32::Foundation::RECT; -use windows_sys::Win32::UI::Controls::SetWindowTheme; +use windows_sys::Win32::UI::Controls::{SetScrollInfo, SetWindowTheme}; use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; use windows_sys::Win32::UI::WindowsAndMessaging::{ - GetSystemMetrics, GetWindowRect, SM_CXSCREEN, SM_CYSCREEN, SPI_GETWORKAREA, SWP_NOACTIVATE, + GetScrollInfo, GetSystemMetrics, GetWindowRect, SB_CTL, SCROLLINFO, SIF_PAGE, SIF_POS, + SIF_RANGE, SM_CXSCREEN, SM_CXVSCROLL, SM_CYSCREEN, SPI_GETWORKAREA, SWP_NOACTIVATE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, SendMessageW, SetWindowPos, SystemParametersInfoW, }; @@ -32,6 +35,10 @@ const ITEM_H: i32 = 22; const GROUP_PADDING: i32 = 6; // bottom padding inside frame const GROUP_GAP: i32 = 5; // gap between groups const BTN_H: i32 = 35; +const BTN_TOP_GAP: i32 = GROUP_GAP; +const MIN_VIEWPORT_H: i32 = 120; +const SCROLLBAR_GAP: i32 = 4; +const WHEEL_SCROLL_STEP: i32 = ITEM_H * 3; const WM_SETFONT: u32 = 0x0030; @@ -66,6 +73,8 @@ pub fn run(ctx: AppContext) { app.window.set_text(&ctx.config.game.title); chrome::apply_icon(&app.window); + let screen_h = unsafe { GetSystemMetrics(SM_CYSCREEN) }; + // --- Addon combo --- let has_addons = ctx.config.addons.as_ref().is_some_and(|a| !a.is_empty()); let mut y: i32 = MARGIN; @@ -95,15 +104,81 @@ pub fn run(ctx: AppContext) { } // --- Option groups --- - let (groups_vec, frames, _group_titles, y) = - build_option_groups(&ctx.config.option_groups, &app.window, y, &fonts); + let groups_top = y; + let measured_groups_h = measure_option_groups_height(&ctx.config.option_groups); + let footer_h = chrome::footer_section_height(); + let max_win_h = (screen_h * 9 / 10).max(400); + let natural_win_h = groups_top + measured_groups_h + BTN_TOP_GAP + BTN_H + footer_h + MARGIN; + let needs_scroll = natural_win_h > max_win_h; + let visible_groups_h = if needs_scroll { + (max_win_h - groups_top - BTN_TOP_GAP - BTN_H - footer_h - MARGIN).max(MIN_VIEWPORT_H) + } else { + measured_groups_h + }; + let scrollbar_w = unsafe { GetSystemMetrics(SM_CXVSCROLL) }.max(12); + let viewport_w = WINDOW_W + - MARGIN * 2 + - if needs_scroll { + scrollbar_w + SCROLLBAR_GAP + } else { + 0 + }; + + let mut scroll_viewport = nwg::Frame::default(); + nwg::Frame::builder() + .size((viewport_w, visible_groups_h.max(1))) + .position((MARGIN, groups_top)) + .flags(nwg::FrameFlags::VISIBLE) + .parent(&app.window) + .build(&mut scroll_viewport) + .expect("Failed to build scroll viewport"); + + let mut scroll_content = nwg::Frame::default(); + nwg::Frame::builder() + .size((viewport_w, measured_groups_h.max(visible_groups_h).max(1))) + .position((0, 0)) + .flags(nwg::FrameFlags::VISIBLE) + .parent(&scroll_viewport) + .build(&mut scroll_content) + .expect("Failed to build scroll content"); + + let (groups_vec, frames, _group_titles, y) = build_option_groups( + &ctx.config.option_groups, + &scroll_content, + 0, + 0, + viewport_w, + &fonts, + ); + debug_assert_eq!(y, measured_groups_h); let groups: Rc>> = Rc::new(RefCell::new(groups_vec)); + let scroll_bar = if needs_scroll { + let mut control = nwg::ScrollBar::default(); + nwg::ScrollBar::builder() + .size((scrollbar_w, visible_groups_h.max(1))) + .position((MARGIN + viewport_w + SCROLLBAR_GAP, groups_top)) + .flags(nwg::ScrollBarFlags::VISIBLE | nwg::ScrollBarFlags::VERTICAL) + .pos(Some(0)) + .parent(&app.window) + .build(&mut control) + .expect("Failed to build vertical scrollbar"); + configure_scrollbar(&control, measured_groups_h, visible_groups_h); + Some(Rc::new(control)) + } else { + None + }; + // --- Bind event handlers --- // WM_COMMAND (BN_CLICKED) goes from each radio/checkbox to its direct parent (the Frame). // So we subclass each Frame, not the individual controls. let frame_handles: Vec = frames.iter().map(|f| f.handle).collect(); + let frame_parent_handle = scroll_content.handle; let window_handle = app.window.handle; + let content_hwnd = match scroll_content.handle { + nwg::ControlHandle::Hwnd(h) => h as *mut c_void, + _ => std::ptr::null_mut(), + }; // Wrap AppContext in Rc for shared mutation across closures let ctx_rc: Rc> = Rc::new(RefCell::new(ctx)); @@ -125,7 +200,7 @@ pub fn run(ctx: AppContext) { let gc = Rc::clone(&groups); let cx = Rc::clone(&ctx_rc); let bh = Rc::clone(&btn_handles); - nwg::bind_event_handler(handle, &window_handle, move |evt, _, _| { + nwg::bind_event_handler(handle, &frame_parent_handle, move |evt, _, _| { if evt == nwg::Event::OnButtonClick { apply_constraints(&cx.borrow().active_title, &gc.borrow()); let ok = i32::from(is_config_complete(&gc.borrow())); @@ -153,8 +228,10 @@ pub fn run(ctx: AppContext) { let cx = Rc::clone(&ctx_rc); let bh = Rc::clone(&btn_handles); let at = Rc::clone(&addon_titles); - let _window_handler = - nwg::bind_event_handler(&window_handle, &window_handle, move |evt, _, handle| { + let _window_handler = nwg::bind_event_handler( + &window_handle, + &window_handle, + move |evt, _evt_data, handle| { if evt == nwg::Event::OnComboxBoxSelection { let idx = unsafe { SendMessageW(combo_hwnd, CB_GETCURSEL, 0, 0) } as usize; if idx < at.len() { @@ -191,11 +268,45 @@ pub fn run(ctx: AppContext) { } } } - }); + }, + ); std::mem::forget(_window_handler); } - chrome::apply_theme(&app.window, &frames); + let _wheel_handler = scroll_bar.as_ref().map(|scroll_bar| { + let scroll_bar = Rc::clone(scroll_bar); + let viewport_handle = scroll_viewport.handle; + + nwg::full_bind_event_handler(&viewport_handle, move |evt, evt_data, _| { + if evt == nwg::Event::OnMouseWheel { + let delta = match evt_data { + nwg::EventData::OnMouseWheel(delta) => delta, + _ => 0, + }; + let notches = if delta == 0 { + 0 + } else { + (delta.abs() / 120).max(1) * delta.signum() + }; + let next_offset = scroll_bar.pos() as i32 - notches * WHEEL_SCROLL_STEP; + set_scroll_offset(&scroll_bar, content_hwnd, next_offset); + } + }) + }); + + let _scroll_handler = scroll_bar.as_ref().map(|scroll_bar| { + let scroll_bar = Rc::clone(scroll_bar); + let scroll_handle = scroll_bar.handle; + + nwg::bind_event_handler(&scroll_handle, &window_handle, move |evt, _, handle| { + if evt == nwg::Event::OnVerticalScroll && handle == scroll_handle { + set_scroll_offset(&scroll_bar, content_hwnd, scroll_bar.pos() as i32); + } + }) + }); + + let dark_panel_handles = [scroll_viewport.handle, scroll_content.handle]; + chrome::apply_theme(&app.window, &dark_panel_handles, &frame_handles); // Apply constraints and set initial button state load_saved_state(&ctx_rc.borrow().selections, &groups.borrow()); @@ -208,12 +319,14 @@ pub fn run(ctx: AppContext) { // --- Save / Apply buttons at the bottom --- let btn_w = (WINDOW_W - MARGIN * 3) / 2; - app.save_button.set_position(MARGIN, y); + let button_y = groups_top + visible_groups_h + BTN_TOP_GAP; + app.save_button.set_position(MARGIN, button_y); app.save_button.set_size(btn_w as u32, BTN_H as u32); - app.apply_button.set_position(MARGIN * 2 + btn_w, y); + app.apply_button.set_position(MARGIN * 2 + btn_w, button_y); app.apply_button.set_size(btn_w as u32, BTN_H as u32); - let content_bottom = chrome::build_footer(&app.window, y + BTN_H, WINDOW_W, MARGIN, &fonts); + let content_bottom = + chrome::build_footer(&app.window, button_y + BTN_H, WINDOW_W, MARGIN, &fonts); let win_h = content_bottom + MARGIN; app.window.set_size(WINDOW_W as u32, win_h as u32); @@ -269,37 +382,126 @@ pub fn run(ctx: AppContext) { nwg::dispatch_thread_events(); } -fn build_option_groups( +fn option_group_item_count(group_def: &crate::config::model::OptionGroup) -> i32 { + (match &group_def.kind { + OptionGroupType::RadioGroup => group_def.radios.as_ref().map_or(0, |items| items.len()), + OptionGroupType::CheckboxGroup => { + group_def.checkboxes.as_ref().map_or(0, |items| items.len()) + } + }) as i32 +} + +fn option_group_frame_height(group_def: &crate::config::model::OptionGroup) -> i32 { + GROUP_TITLE_H + option_group_item_count(group_def) * ITEM_H + GROUP_PADDING +} + +fn measure_option_groups_height(group_defs: &[crate::config::model::OptionGroup]) -> i32 { + let content_h: i32 = group_defs.iter().map(option_group_frame_height).sum(); + let gap_h = GROUP_GAP * group_defs.len().saturating_sub(1) as i32; + content_h + gap_h +} + +fn scroll_bar_hwnd(scroll_bar: &nwg::ScrollBar) -> Option<*mut c_void> { + match scroll_bar.handle { + nwg::ControlHandle::Hwnd(h) => Some(h as *mut c_void), + _ => None, + } +} + +fn get_scroll_bar_info(scroll_bar: &nwg::ScrollBar) -> Option { + let hwnd = scroll_bar_hwnd(scroll_bar)?; + let mut info: SCROLLINFO = unsafe { std::mem::zeroed() }; + info.cbSize = std::mem::size_of::() as u32; + info.fMask = SIF_RANGE | SIF_PAGE | SIF_POS; + + let ok = unsafe { GetScrollInfo(hwnd, SB_CTL, &mut info) }; + if ok == 0 { None } else { Some(info) } +} + +fn configure_scrollbar(scroll_bar: &nwg::ScrollBar, content_height: i32, page_height: i32) { + let Some(hwnd) = scroll_bar_hwnd(scroll_bar) else { + return; + }; + + let mut info: SCROLLINFO = unsafe { std::mem::zeroed() }; + info.cbSize = std::mem::size_of::() as u32; + info.fMask = SIF_RANGE | SIF_PAGE | SIF_POS; + info.nMin = 0; + info.nMax = content_height.saturating_sub(1).max(0); + info.nPage = page_height.max(1) as u32; + info.nPos = 0; + + unsafe { + SetScrollInfo(hwnd, SB_CTL, &info, 1); + } +} + +fn set_scroll_bar_pos(scroll_bar: &nwg::ScrollBar, position: i32) { + let Some(hwnd) = scroll_bar_hwnd(scroll_bar) else { + return; + }; + let Some(mut info) = get_scroll_bar_info(scroll_bar) else { + scroll_bar.set_pos(position.max(0) as usize); + return; + }; + + info.fMask = SIF_POS; + info.nPos = position; + + unsafe { + SetScrollInfo(hwnd, SB_CTL, &info, 1); + } +} + +fn scroll_bar_max_offset(scroll_bar: &nwg::ScrollBar) -> i32 { + get_scroll_bar_info(scroll_bar) + .map(|info| { + let page = (info.nPage as i32).max(1); + info.nMax.saturating_sub(page - 1).max(info.nMin) + }) + .unwrap_or(0) +} + +fn set_scroll_offset(scroll_bar: &nwg::ScrollBar, content_hwnd: *mut c_void, offset: i32) { + let clamped = offset.clamp(0, scroll_bar_max_offset(scroll_bar)); + set_scroll_bar_pos(scroll_bar, clamped); + + unsafe { + SetWindowPos( + content_hwnd, + std::ptr::null_mut(), + 0, + -clamped, + 0, + 0, + SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOZORDER, + ); + } +} + +fn build_option_groups + Clone>( group_defs: &[crate::config::model::OptionGroup], - window: &nwg::Window, + parent: C, + x: i32, y_start: i32, + frame_w: i32, fonts: &chrome::AppFonts, ) -> (Vec, Vec, Vec, i32) { let mut groups: Vec = Vec::new(); let mut frames: Vec = Vec::new(); let mut group_titles: Vec = Vec::new(); let mut y = y_start; - let inner_w = WINDOW_W - MARGIN * 2 - 8; + let inner_w = frame_w - 8; - for group_def in group_defs { - let item_count = match &group_def.kind { - OptionGroupType::RadioGroup => { - group_def.radios.as_ref().map_or(0, |v: &Vec<_>| v.len()) - } - OptionGroupType::CheckboxGroup => group_def - .checkboxes - .as_ref() - .map_or(0, |v: &Vec<_>| v.len()), - } as i32; - - let frame_h = GROUP_TITLE_H + item_count * ITEM_H + GROUP_PADDING; + for (index, group_def) in group_defs.iter().enumerate() { + let frame_h = option_group_frame_height(group_def); let mut frame = nwg::Frame::default(); nwg::Frame::builder() - .size((WINDOW_W - MARGIN * 2, frame_h)) - .position((MARGIN, y)) + .size((frame_w, frame_h)) + .position((x, y)) .flags(nwg::FrameFlags::VISIBLE | nwg::FrameFlags::BORDER) - .parent(window) + .parent(parent.clone()) .build(&mut frame) .expect("Failed to build Frame"); @@ -386,7 +588,10 @@ fn build_option_groups( } } - y += frame_h + GROUP_GAP; + y += frame_h; + if index + 1 < group_defs.len() { + y += GROUP_GAP; + } group_titles.push(title); frames.push(frame); } diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index 36f4fb0..8f5190d 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -1,4 +1,5 @@ use native_windows_gui as nwg; +use nwg::ControlHandle; use windows_sys::Win32::Foundation::RECT; use windows_sys::Win32::Graphics::Gdi::{ CreateFontIndirectW, CreateSolidBrush, DeleteObject, FillRect, SetBkMode, SetTextColor, @@ -94,7 +95,7 @@ pub fn apply_icon(window: &nwg::Window) { // --- Theming --- -pub fn apply_theme(window: &nwg::Window, frames: &[nwg::Frame]) { +pub fn apply_theme(window: &nwg::Window, dark_panels: &[ControlHandle], frames: &[ControlHandle]) { let brush_dark = unsafe { CreateSolidBrush(COLOR_BG_DARK) } as isize; let brush_bg = unsafe { CreateSolidBrush(COLOR_BG) } as isize; let brush_light = unsafe { CreateSolidBrush(COLOR_BG_LIGHT) } as isize; @@ -126,58 +127,88 @@ pub fn apply_theme(window: &nwg::Window, frames: &[nwg::Frame]) { .ok(); let _ = _theme_win; // RawEventHandler has no Drop — handler stays registered at OS level + let _theme_panels: Vec<_> = dark_panels + .iter() + .enumerate() + .map(|(i, handle)| { + nwg::bind_raw_event_handler(handle, 0x10020 + i, move |hwnd, msg, w, _| match msg { + WM_ERASEBKGND => { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { + GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); + FillRect(hdc, &rect, brush_dark as usize as *mut std::ffi::c_void); + } + Some(1) + } + WM_CTLCOLORSTATIC => { + let hdc = w as *mut std::ffi::c_void; + unsafe { + SetTextColor(hdc, COLOR_TEXT_TITLES); + SetBkMode(hdc, 1); + } + Some(brush_dark) + } + _ => None, + }) + .ok() + }) + .collect(); + std::mem::forget(_theme_panels); + // Frame backgrounds + radio/checkbox colors - let _theme_frames: Vec<_> = - frames - .iter() - .enumerate() - .map(|(i, frame)| { - nwg::bind_raw_event_handler(&frame.handle, 0x10002 + i, move |hwnd, msg, w, l| { - match msg { - WM_ERASEBKGND => { - let hdc = w as *mut std::ffi::c_void; - let mut rect: RECT = unsafe { std::mem::zeroed() }; - unsafe { - GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); - } - unsafe { - FillRect(hdc, &rect, brush_light as usize as *mut std::ffi::c_void); - } - Some(1) - } - WM_CTLCOLORSTATIC => { - let hdc = w as *mut std::ffi::c_void; - let ctrl_hwnd = l as usize as *mut std::ffi::c_void; - let is_enabled = unsafe { IsWindowEnabled(ctrl_hwnd) } != 0; - unsafe { - SetBkMode(hdc, 1); - } - if is_enabled { - let mut class: [u16; 16] = [0; 16]; - let n = unsafe { GetClassNameW(ctrl_hwnd, class.as_mut_ptr(), 16) }; - let is_button = n > 0 && class[0] == b'B' as u16; - let color = if is_button { - COLOR_TEXT_LABELS - } else { - COLOR_TEXT_TITLES - }; - unsafe { - SetTextColor(hdc, color); - } - } - Some(brush_light) + let _theme_frames: Vec<_> = frames + .iter() + .enumerate() + .map(|(i, frame)| { + nwg::bind_raw_event_handler(frame, 0x10002 + i, move |hwnd, msg, w, l| match msg { + WM_ERASEBKGND => { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { + GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); + } + unsafe { + FillRect(hdc, &rect, brush_light as usize as *mut std::ffi::c_void); + } + Some(1) + } + WM_CTLCOLORSTATIC => { + let hdc = w as *mut std::ffi::c_void; + let ctrl_hwnd = l as usize as *mut std::ffi::c_void; + let is_enabled = unsafe { IsWindowEnabled(ctrl_hwnd) } != 0; + unsafe { + SetBkMode(hdc, 1); + } + if is_enabled { + let mut class: [u16; 16] = [0; 16]; + let n = unsafe { GetClassNameW(ctrl_hwnd, class.as_mut_ptr(), 16) }; + let is_button = n > 0 && class[0] == b'B' as u16; + let color = if is_button { + COLOR_TEXT_LABELS + } else { + COLOR_TEXT_TITLES + }; + unsafe { + SetTextColor(hdc, color); } - _ => None, } - }) - .ok() + Some(brush_light) + } + _ => None, }) - .collect(); + .ok() + }) + .collect(); std::mem::forget(_theme_frames); } // --- Footer --- +pub fn footer_section_height() -> i32 { + 6 + 8 + FOOTER_LABEL_H + FOOTER_GAP + FOOTER_LINK_H +} + /// Builds the separator + footer labels below the buttons. /// `y_after_btns` = y + BTN_H (top of the footer zone). /// Returns `content_bottom` — add MARGIN to get `win_h`.