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
267 changes: 236 additions & 31 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<RefCell<Vec<Group>>> = 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<nwg::ControlHandle> = 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<RefCell> for shared mutation across closures
let ctx_rc: Rc<RefCell<AppContext>> = Rc::new(RefCell::new(ctx));
Expand All @@ -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()));
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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());
Expand All @@ -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);

Expand Down Expand Up @@ -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<SCROLLINFO> {
let hwnd = scroll_bar_hwnd(scroll_bar)?;
let mut info: SCROLLINFO = unsafe { std::mem::zeroed() };
info.cbSize = std::mem::size_of::<SCROLLINFO>() 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::<SCROLLINFO>() 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<C: Into<ControlHandle> + Clone>(
group_defs: &[crate::config::model::OptionGroup],
window: &nwg::Window,
parent: C,
x: i32,
y_start: i32,
frame_w: i32,
fonts: &chrome::AppFonts,
) -> (Vec<Group>, Vec<nwg::Frame>, Vec<nwg::Label>, i32) {
let mut groups: Vec<Group> = Vec::new();
let mut frames: Vec<nwg::Frame> = Vec::new();
let mut group_titles: Vec<nwg::Label> = 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");

Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading