From 66294728118d718b31537db756a08944a39871ae Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 16:04:20 +0100 Subject: [PATCH] Scroll long menus and lists with the mouse wheel or a swipe Touch clicks in menus happen on release without a swipe. The first-start language picker is a flag grid that scrolls. Co-Authored-By: Claude Opus 5.5 --- src/main.rs | 57 ++++++++++++++++++++++++++++++++++++++----- src/menu.rs | 58 +++++++++++++++++++++++++++++++++++++------- src/menu/bestiary.rs | 10 ++++++++ src/ui.rs | 57 +++++++++++++++++++++++++++++++++++-------- 4 files changed, 157 insertions(+), 25 deletions(-) diff --git a/src/main.rs b/src/main.rs index 400418c..4f635fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -842,6 +842,8 @@ async fn main() { let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None }; let mut touch = TouchInput::new(); let mut auto_touch = platform::coarse_pointer(); + let mut menu_swipe: Option<(f32, f32)> = None; // a swipe over a menu: last finger y, unused distance + let mut menu_dragged = false; // the finger on a menu has swiped (no click when it lifts) // until the mouse has really moved its position is (0, 0): no edge scrolling before that let mut mouse_moved = false; // automatic mode: touch interface after a touch, desktop after a mouse click let mut autosave_t = 0.0f32; @@ -1025,11 +1027,45 @@ async fn main() { } } let (_, wheel) = mouse_wheel(); - if wheel != 0.0 && menu.page.is_some() { - let d = if wheel > 0.0 { -1 } else { 1 }; - // an open language dropdown scrolls first - if !menu.lang_wheel(d) && menu.scrollable() { - menu.scroll_lines(d); + if wheel != 0.0 && ui.show_langsel { + ui.langsel_wheel(if wheel > 0.0 { -1 } else { 1 }); + } else if wheel != 0.0 && menu.page.is_some() { + menu.wheel(if wheel > 0.0 { -1 } else { 1 }); + } else if wheel != 0.0 { + ui.overview_wheel(ux, uy, if wheel > 0.0 { -1 } else { 1 }); + } + // touch: a swipe over a menu, the language grid or the creature overview scrolls it, + // one line per row height + if touch_mode { + for g in &gestures { + match *g { + Gesture::Press(_) => menu_dragged = false, + Gesture::DragStart(p) if menu.page.is_some() || ui.show_langsel || ui.overview_wheel(p.x, p.y, 0) => { + menu_swipe = Some((p.y, 0.0)); + menu_dragged = true; + } + Gesture::DragMove(p) => { + if let Some((last, acc)) = menu_swipe { + let mut acc = acc + p.y - last; + let step = ui::CH() * 2.0; + while acc.abs() >= step { + // finger up: the content follows it up, so the list scrolls down + let d = if acc < 0.0 { 1 } else { -1 }; + if ui.show_langsel { + ui.langsel_wheel(d); + } else if menu.page.is_some() { + menu.wheel(d); + } else { + ui.overview_wheel(p.x, p.y, d); + } + acc += d as f32 * step; + } + menu_swipe = Some((p.y, acc)); + } + } + Gesture::DragEnd | Gesture::Cancel => menu_swipe = None, + _ => {} + } } } @@ -1269,7 +1305,16 @@ async fn main() { } // ---------------- left button (touch: a finger touching the interface) - let pressed = if touch_mode { gestures.iter().any(|g| matches!(g, Gesture::Press(_))) } else { is_mouse_button_pressed(MouseButton::Left) }; + // (in menus and dialogs a touch clicks when the finger lifts without a swipe, so that + // swiping a list scrolls it instead of picking the entry under the finger) + let menu_touch = touch_mode && (in_menu || dialog); + let pressed = if menu_touch { + !menu_dragged && gestures.iter().any(|g| matches!(g, Gesture::Release(_))) + } else if touch_mode { + gestures.iter().any(|g| matches!(g, Gesture::Press(_))) + } else { + is_mouse_button_pressed(MouseButton::Left) + }; if touch_mode && pressed { inp.touch_world = playing && !over_ui; inp.inspect = None; diff --git a/src/menu.rs b/src/menu.rs index 33d2fb7..b6634e7 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -15,7 +15,7 @@ use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side use macroquad::prelude::*; mod bestiary; -mod flags; +pub mod flags; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Page { @@ -645,6 +645,7 @@ impl Menu { MenuAction::Consumed } Some(Btn::Tab(t)) => { + self.scroll = 0; self.page = Some(Page::Settings { tab: t, from_title }); MenuAction::Consumed } @@ -1171,9 +1172,17 @@ impl Menu { let half = (acts.len() + 1) / 2; let colw = (cols as f32 - 6.0) * CW() * 0.5; let rowh = CH(); + // on small screens the two columns scroll (mouse wheel, swipe) + let vis = (((y + (rows - 6) as f32 * CH() - top) / rowh).floor() as usize).max(1).min(half); + self.rows_visible = vis; + self.scroll = self.scroll.min(half - vis); for (k, a) in acts.iter().enumerate() { let c = k / half; let r = k % half; + if r < self.scroll || r >= self.scroll + vis { + continue; + } + let r = r - self.scroll; let bx = x + CW() * 2.0 + c as f32 * (colw + CW() * 2.0); let by = top + r as f32 * rowh; let rect = Rect::new(bx, by - 2.0, colw, rowh - 2.0); @@ -1188,8 +1197,12 @@ impl Menu { gfx.text(&key, bx + colw - gfx.measure(&key, S()) - CW() * 0.5, by, S(), GOLD); self.buttons.push((rect, Btn::Key(k))); } + if vis < half { + let pos = format!("{}-{}/{}", self.scroll + 1, self.scroll + vis, half); + gfx.text(&pos, x + cols as f32 * CW() - gfx.measure(&pos, SMALL()) - CW() * 2.0, top - CH() * 1.2, SMALL(), DIM); + } let hint = tr("set.keys_hint"); - gfx.text(&hint, x + CW() * 2.0, top + half as f32 * rowh + CH() * 0.5, SMALL(), DIM); + gfx.text(&hint, x + CW() * 2.0, top + vis as f32 * rowh + CH() * 0.5, SMALL(), DIM); let rw = gfx.measure(&tr("set.reset_keys"), S()) + CW() * 4.0; let rr = Rect::new(x + cols as f32 * CW() - rw - CW() * 2.0, y + (rows - 4) as f32 * CH(), rw, CH() * 2.0); button(gfx, rr, &tr("set.reset_keys"), rr.contains(vec2(mx, my)), FRAME); @@ -1612,7 +1625,22 @@ impl Menu { // scrolling on pages with lists (mouse wheel) pub fn scrollable(&self) -> bool { - matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. })) + matches!( + self.page, + Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account) + ) + } + + // mouse wheel or a swipe: scrolls what is under it (an open dropdown first); d lines down + pub fn wheel(&mut self, d: i32) { + if self.lang_wheel(d) { + return; + } + if matches!(self.page, Some(Page::Bestiary { .. })) { + self.bestiary_wheel(d); + } else if self.scrollable() { + self.scroll_lines(d); + } } pub fn scroll_lines(&mut self, d: i32) { @@ -1729,11 +1757,16 @@ impl Menu { let sure = tr("saves.sure"); let dw = gfx.measure(&del, S()).max(gfx.measure(&sure, S())) + CW() * 3.0; let bottom = y + (rows - 5) as f32 * CH(); - for (k, u) in cloud.users.iter().enumerate() { - let ry = yy + k as f32 * CH() * 2.5; - if ry + CH() * 2.0 > bottom { - break; - } + // more users than fit: the list scrolls (mouse wheel, swipe) + let vis = (((bottom - yy) / (CH() * 2.5)).floor() as usize).max(1); + self.rows_visible = vis; + self.scroll = self.scroll.min(cloud.users.len().saturating_sub(vis)); + if cloud.users.len() > vis { + let pos = format!("{}-{}/{}", self.scroll + 1, (self.scroll + vis).min(cloud.users.len()), cloud.users.len()); + gfx.text(&pos, ix + w - gfx.measure(&pos, SMALL()), yy - CH() * 1.2, SMALL(), DIM); + } + for (k, u) in cloud.users.iter().enumerate().skip(self.scroll).take(vis) { + let ry = yy + (k - self.scroll) as f32 * CH() * 2.5; let label = if u.admin { format!("{} (admin)", u.name) } else { u.name.clone() }; gfx.text(&label, ix, ry + CH() * 0.5, S(), TXT); if !u.admin { @@ -1847,7 +1880,14 @@ impl Menu { if !ui.rooms.is_empty() && !ui.started { gfx.text(&tr("net.rooms"), ix, yy, S(), GOLD); yy += CH() * 1.4; - for (k, room) in ui.rooms.iter().take(4).enumerate() { + // more than four open matches: the list scrolls (mouse wheel, swipe) + self.rows_visible = 4; + self.scroll = self.scroll.min(ui.rooms.len().saturating_sub(4)); + if ui.rooms.len() > 4 { + let pos = format!("{}-{}/{}", self.scroll + 1, self.scroll + 4, ui.rooms.len()); + gfx.text(&pos, ix + 25.0 * CW(), yy - CH() * 1.4, SMALL(), DIM); + } + for (k, room) in ui.rooms.iter().enumerate().skip(self.scroll).take(4) { let label = format!("{} {}{}{}", room.code, room.players, if room.started { " *" } else { "" }, if room.paused { " p" } else { "" }); let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0); button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); diff --git a/src/menu/bestiary.rs b/src/menu/bestiary.rs index c4e5dde..50ae1f4 100644 --- a/src/menu/bestiary.rs +++ b/src/menu/bestiary.rs @@ -92,6 +92,16 @@ impl Menu { self.page = Some(page); } + // mouse wheel / swipe: the previous or next entry (no wrap-around) + pub(super) fn bestiary_wheel(&mut self, d: i32) { + let Some(Page::Bestiary { tab, sel, .. }) = self.page else { return }; + let n = entries(tab).len() as i32; + let s = sel as i32 + d; + if s >= 0 && s < n { + self.bestiary_click(Btn::BestSel(d)); + } + } + pub(super) fn draw_bestiary(&mut self, gfx: &Gfx, tab: usize, sel: usize, level: u32) { let (sw, sh) = (sw(), sh()); let (mx, my) = mouse(); diff --git a/src/ui.rs b/src/ui.rs index 1292306..4cfa65b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -370,6 +370,7 @@ pub struct Ui { pub show_help: bool, pub show_overview: bool, // list of all creatures ov_scroll: usize, + ov_rect: Option, // where the creature overview is (mouse wheel, swipe) pub show_langsel: bool, pub log: Vec<(Msg, f64)>, pub chat_log: Vec<(Option, String, String, f64)>, @@ -387,6 +388,7 @@ pub struct Ui { end_buttons: Vec<(Rect, u8)>, goals_rect: Rect, // mission goals panel (click: briefing) // end dialog: 0 new game, 1 next/again, 2 campaign lang_rects: Vec<(Rect, String)>, + langsel_scroll: usize, // first line of the first-start language grid pub hand_anim: Option<(u8, f64)>, // 1 = backhand, 2 = punch, start time // touch interface open_cat: Option, @@ -430,6 +432,7 @@ impl Ui { show_help: true, show_overview: false, ov_scroll: 0, + ov_rect: None, show_langsel: false, log: Vec::new(), chat_log: Vec::new(), @@ -447,6 +450,7 @@ impl Ui { end_buttons: Vec::new(), goals_rect: Rect::new(0.0, 0.0, 0.0, 0.0), lang_rects: Vec::new(), + langsel_scroll: 0, hand_anim: None, open_cat: None, tip: None, @@ -1026,6 +1030,7 @@ impl Ui { let y = (bottom - rows as f32 * CH()).max(top); gfx.window_titled(x, y, cols, rows, S(), false, FRAME_HI, BG, &tr("ov.title"), FRAME_HI); self.rects.push(Rect::new(x, y, cols as f32 * CW(), rows as f32 * CH())); + self.ov_rect = Some(Rect::new(x, y, cols as f32 * CW(), rows as f32 * CH())); let mine: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && e.creature()).collect(); let wage: f32 = mine.iter().map(|e| st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)).sum(); let mood = if mine.is_empty() { 0.0 } else { mine.iter().map(|e| e.happy.clamp(0.0, 1.0)).sum::() / mine.len() as f32 }; @@ -1152,39 +1157,71 @@ impl Ui { } // First start: choose the language (names shown in their own language) + // first start: every language as a button with its flag, in a grid fn draw_langsel(&mut self, gfx: &Gfx) { + use crate::menu::flags::{draw_flag, FLAG_H, FLAG_W}; let sw = sw(); let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.6)); let langs = languages(); - let cols = 50; - let rows = 6 + langs.len() as i32 * 3; + let bw_c = 22; // button width in characters + let grid = (((sw / CW()) as i32 - 6) / (bw_c + 2)).clamp(1, 3); + let lines = (langs.len() as i32 + grid - 1) / grid; + let cols = (grid * (bw_c + 2) + 4).min((sw / CW()) as i32); + let rows = (lines * 3 + 6).min((sh / CH()) as i32); let x = (grid_center(sw, cols as f32 * CW())).max(0.0); let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); - // the title shows all language names, since no language is chosen yet - let title = langs.iter().map(|(c, _)| crate::i18n::tr_in(c, "langsel.title")).collect::>().join(" / "); + // no language is chosen yet: the title in English, the hint in English and German + let title = crate::i18n::tr_in("en", "langsel.title"); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); self.lang_rects.clear(); let (mx, my) = mouse(); + // more lines than fit: the grid scrolls (mouse wheel, swipe) + let fit = ((rows - 6) / 3).max(1); + self.langsel_scroll = self.langsel_scroll.min((lines - fit).max(0) as usize); + let bw = bw_c as f32 * CW(); + let gx = x + (cols as f32 * CW() - grid as f32 * (bw + 2.0 * CW()) + 2.0 * CW()) * 0.5; for (k, (code, name)) in langs.iter().enumerate() { - let bw = 24.0 * CW(); - let bx = grid_center(sw, bw); - let by = y + CH() * (2.0 + k as f32 * 3.0); + let line = k as i32 / grid - self.langsel_scroll as i32; + if line < 0 || line >= fit { + continue; + } + let bx = gx + (k as i32 % grid) as f32 * (bw + 2.0 * CW()); + let by = y + CH() * (2.0 + line as f32 * 3.0); let r = Rect::new(bx, by, bw, CH() * 2.0); let hov = r.contains(vec2(mx, my)); draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hov { FRAME_HI } else { FRAME }); - gfx.text(name, bx + (bw - gfx.measure(name, S())) * 0.5, by + CH() * 0.5, S(), TXT); + let px = S(); + draw_flag(code, (bx + CW() * 0.75).floor(), (by + (r.h - FLAG_H as f32 * px) * 0.5).floor(), px); + let tx = bx + CW() * 0.75 + FLAG_W as f32 * px + CW(); + let name: String = name.chars().take(((bx + bw - tx) / CW()).max(1.0) as usize - 1).collect(); + gfx.text(&name, tx, by + CH() * 0.5, S(), TXT); self.lang_rects.push((r, code.clone())); } - let hint_y = y + CH() * (2.0 + langs.len() as f32 * 3.0 + 0.5); - for (k, (c, _)) in langs.iter().enumerate() { + let hint_y = y + CH() * (2.0 + fit as f32 * 3.0 + 0.5); + for (k, c) in ["en", "de"].iter().enumerate() { let h = crate::i18n::tr_in(c, "langsel.hint"); + let h: String = h.chars().take((cols as usize).saturating_sub(4) * 2).collect(); gfx.text(&h, x + CW() * 2.0, hint_y + CH() * k as f32, SMALL(), DIM); } self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } + // mouse wheel / swipe over the creature overview: scrolls it; true if it was there + pub fn overview_wheel(&mut self, x: f32, y: f32, d: i32) -> bool { + if !self.show_overview || !self.ov_rect.map_or(false, |r| r.contains(vec2(x, y))) { + return false; + } + self.ov_scroll = (self.ov_scroll as i32 + d).max(0) as usize; + true + } + + // mouse wheel / swipe on the first-start language grid + pub fn langsel_wheel(&mut self, d: i32) { + self.langsel_scroll = (self.langsel_scroll as i32 + d).max(0) as usize; + } + fn draw_end(&mut self, g: &Game, gfx: &Gfx) { let sw = sw(); let sh = sh();