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 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 16:04:20 +01:00
1 parent d8e9184a9c
commit 6629472811
4 files changed
+156 -24

No files matched your search

+51 -6
View File
@@ -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 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 touch = TouchInput::new();
let mut auto_touch = platform::coarse_pointer(); 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 // 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 mouse_moved = false; // automatic mode: touch interface after a touch, desktop after a mouse click
let mut autosave_t = 0.0f32; let mut autosave_t = 0.0f32;
@@ -1025,11 +1027,45 @@ async fn main() {
} }
} }
let (_, wheel) = mouse_wheel(); let (_, wheel) = mouse_wheel();
if wheel != 0.0 && menu.page.is_some() { if wheel != 0.0 && ui.show_langsel {
let d = if wheel > 0.0 { -1 } else { 1 }; ui.langsel_wheel(if wheel > 0.0 { -1 } else { 1 });
// an open language dropdown scrolls first } else if wheel != 0.0 && menu.page.is_some() {
if !menu.lang_wheel(d) && menu.scrollable() { menu.wheel(if wheel > 0.0 { -1 } else { 1 });
menu.scroll_lines(d); } 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) // ---------------- 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 { if touch_mode && pressed {
inp.touch_world = playing && !over_ui; inp.touch_world = playing && !over_ui;
inp.inspect = None; inp.inspect = None;
+48 -8
View File
@@ -15,7 +15,7 @@ use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side
use macroquad::prelude::*; use macroquad::prelude::*;
mod bestiary; mod bestiary;
mod flags; pub mod flags;
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Page { pub enum Page {
@@ -645,6 +645,7 @@ impl Menu {
MenuAction::Consumed MenuAction::Consumed
} }
Some(Btn::Tab(t)) => { Some(Btn::Tab(t)) => {
self.scroll = 0;
self.page = Some(Page::Settings { tab: t, from_title }); self.page = Some(Page::Settings { tab: t, from_title });
MenuAction::Consumed MenuAction::Consumed
} }
@@ -1171,9 +1172,17 @@ impl Menu {
let half = (acts.len() + 1) / 2; let half = (acts.len() + 1) / 2;
let colw = (cols as f32 - 6.0) * CW() * 0.5; let colw = (cols as f32 - 6.0) * CW() * 0.5;
let rowh = CH(); 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() { for (k, a) in acts.iter().enumerate() {
let c = k / half; let c = k / half;
let r = 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 bx = x + CW() * 2.0 + c as f32 * (colw + CW() * 2.0);
let by = top + r as f32 * rowh; let by = top + r as f32 * rowh;
let rect = Rect::new(bx, by - 2.0, colw, rowh - 2.0); 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); gfx.text(&key, bx + colw - gfx.measure(&key, S()) - CW() * 0.5, by, S(), GOLD);
self.buttons.push((rect, Btn::Key(k))); 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"); 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 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); 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); 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) // scrolling on pages with lists (mouse wheel)
pub fn scrollable(&self) -> bool { 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) { pub fn scroll_lines(&mut self, d: i32) {
@@ -1729,11 +1757,16 @@ impl Menu {
let sure = tr("saves.sure"); let sure = tr("saves.sure");
let dw = gfx.measure(&del, S()).max(gfx.measure(&sure, S())) + CW() * 3.0; let dw = gfx.measure(&del, S()).max(gfx.measure(&sure, S())) + CW() * 3.0;
let bottom = y + (rows - 5) as f32 * CH(); let bottom = y + (rows - 5) as f32 * CH();
for (k, u) in cloud.users.iter().enumerate() { // more users than fit: the list scrolls (mouse wheel, swipe)
let ry = yy + k as f32 * CH() * 2.5; let vis = (((bottom - yy) / (CH() * 2.5)).floor() as usize).max(1);
if ry + CH() * 2.0 > bottom { self.rows_visible = vis;
break; 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() }; let label = if u.admin { format!("{} (admin)", u.name) } else { u.name.clone() };
gfx.text(&label, ix, ry + CH() * 0.5, S(), TXT); gfx.text(&label, ix, ry + CH() * 0.5, S(), TXT);
if !u.admin { if !u.admin {
@@ -1847,7 +1880,14 @@ impl Menu {
if !ui.rooms.is_empty() && !ui.started { if !ui.rooms.is_empty() && !ui.started {
gfx.text(&tr("net.rooms"), ix, yy, S(), GOLD); gfx.text(&tr("net.rooms"), ix, yy, S(), GOLD);
yy += CH() * 1.4; 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 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); let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0);
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
+10
View File
@@ -92,6 +92,16 @@ impl Menu {
self.page = Some(page); 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) { pub(super) fn draw_bestiary(&mut self, gfx: &Gfx, tab: usize, sel: usize, level: u32) {
let (sw, sh) = (sw(), sh()); let (sw, sh) = (sw(), sh());
let (mx, my) = mouse(); let (mx, my) = mouse();
+47 -10
View File
@@ -370,6 +370,7 @@ pub struct Ui {
pub show_help: bool, pub show_help: bool,
pub show_overview: bool, // list of all creatures pub show_overview: bool, // list of all creatures
ov_scroll: usize, ov_scroll: usize,
ov_rect: Option<Rect>, // where the creature overview is (mouse wheel, swipe)
pub show_langsel: bool, pub show_langsel: bool,
pub log: Vec<(Msg, f64)>, pub log: Vec<(Msg, f64)>,
pub chat_log: Vec<(Option<u8>, String, String, f64)>, pub chat_log: Vec<(Option<u8>, String, String, f64)>,
@@ -387,6 +388,7 @@ pub struct Ui {
end_buttons: Vec<(Rect, u8)>, end_buttons: Vec<(Rect, u8)>,
goals_rect: Rect, // mission goals panel (click: briefing) // end dialog: 0 new game, 1 next/again, 2 campaign goals_rect: Rect, // mission goals panel (click: briefing) // end dialog: 0 new game, 1 next/again, 2 campaign
lang_rects: Vec<(Rect, String)>, 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 pub hand_anim: Option<(u8, f64)>, // 1 = backhand, 2 = punch, start time
// touch interface // touch interface
open_cat: Option<usize>, open_cat: Option<usize>,
@@ -430,6 +432,7 @@ impl Ui {
show_help: true, show_help: true,
show_overview: false, show_overview: false,
ov_scroll: 0, ov_scroll: 0,
ov_rect: None,
show_langsel: false, show_langsel: false,
log: Vec::new(), log: Vec::new(),
chat_log: Vec::new(), chat_log: Vec::new(),
@@ -447,6 +450,7 @@ impl Ui {
end_buttons: Vec::new(), end_buttons: Vec::new(),
goals_rect: Rect::new(0.0, 0.0, 0.0, 0.0), goals_rect: Rect::new(0.0, 0.0, 0.0, 0.0),
lang_rects: Vec::new(), lang_rects: Vec::new(),
langsel_scroll: 0,
hand_anim: None, hand_anim: None,
open_cat: None, open_cat: None,
tip: None, tip: None,
@@ -1026,6 +1030,7 @@ impl Ui {
let y = (bottom - rows as f32 * CH()).max(top); 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); 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.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 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 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::<f32>() / mine.len() as f32 }; let mood = if mine.is_empty() { 0.0 } else { mine.iter().map(|e| e.happy.clamp(0.0, 1.0)).sum::<f32>() / mine.len() as f32 };
@@ -1152,39 +1157,71 @@ impl Ui {
} }
// First start: choose the language (names shown in their own language) // 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) { fn draw_langsel(&mut self, gfx: &Gfx) {
use crate::menu::flags::{draw_flag, FLAG_H, FLAG_W};
let sw = sw(); let sw = sw();
let sh = sh(); let sh = sh();
draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.6)); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.6));
let langs = languages(); let langs = languages();
let cols = 50; let bw_c = 22; // button width in characters
let rows = 6 + langs.len() as i32 * 3; 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 x = (grid_center(sw, cols as f32 * CW())).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).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 // no language is chosen yet: the title in English, the hint in English and German
let title = langs.iter().map(|(c, _)| crate::i18n::tr_in(c, "langsel.title")).collect::<Vec<_>>().join(" / "); 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); 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(); self.lang_rects.clear();
let (mx, my) = mouse(); 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() { for (k, (code, name)) in langs.iter().enumerate() {
let bw = 24.0 * CW(); let line = k as i32 / grid - self.langsel_scroll as i32;
let bx = grid_center(sw, bw); if line < 0 || line >= fit {
let by = y + CH() * (2.0 + k as f32 * 3.0); 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 r = Rect::new(bx, by, bw, CH() * 2.0);
let hov = r.contains(vec2(mx, my)); 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(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 }); 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())); self.lang_rects.push((r, code.clone()));
} }
let hint_y = y + CH() * (2.0 + langs.len() as f32 * 3.0 + 0.5); let hint_y = y + CH() * (2.0 + fit as f32 * 3.0 + 0.5);
for (k, (c, _)) in langs.iter().enumerate() { for (k, c) in ["en", "de"].iter().enumerate() {
let h = crate::i18n::tr_in(c, "langsel.hint"); 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); 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)); 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) { fn draw_end(&mut self, g: &Game, gfx: &Gfx) {
let sw = sw(); let sw = sw();
let sh = sh(); let sh = sh();