diff --git a/src/main.rs b/src/main.rs index 48c5703..400418c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1024,10 +1024,12 @@ async fn main() { } } } - if menu.scrollable() { - let (_, wheel) = mouse_wheel(); - if wheel != 0.0 { - menu.scroll_lines(if wheel > 0.0 { -1 } else { 1 }); + 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); } } diff --git a/src/menu.rs b/src/menu.rs index 1bbf983..33d2fb7 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -15,6 +15,7 @@ use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side use macroquad::prelude::*; mod bestiary; +mod flags; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Page { @@ -289,6 +290,7 @@ enum Btn { BestSel(i32), BestPick(usize), BestLevel(i32), + LangOpen, // the language dropdown field } pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft"; @@ -417,6 +419,11 @@ pub struct Menu { scroll: usize, rows_visible: usize, buttons: Vec<(Rect, Btn)>, + lang_open: Option, // the language dropdown is open below/above this field + lang_scroll: usize, // first language shown in the open dropdown + lang_opts: Vec<(Rect, String)>, // the open dropdown's rows (language codes) + lang_rows: usize, // rows the open dropdown shows at once + cur_lang: String, // the language in use (for the field and the highlight) } fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) { @@ -445,6 +452,11 @@ impl Menu { campaign_pending: false, confirm: None, scroll: 0, + lang_open: None, + lang_scroll: 0, + lang_opts: Vec::new(), + lang_rows: 0, + cur_lang: String::new(), rows_visible: 1, buttons: Vec::new(), } @@ -456,6 +468,7 @@ impl Menu { // Esc: go one level back pub fn back(&mut self) { + self.lang_open = None; self.page = match self.page { Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 5, from_title }), Some(Page::Settings { from_title: true, .. }) => Some(Page::Title), @@ -574,6 +587,15 @@ impl Menu { if self.page.is_none() { return MenuAction::None; } + // an open language dropdown takes the click: a language, or anywhere else closes it + if self.lang_open.take().is_some() { + if let Some((_, code)) = self.lang_opts.iter().find(|(r, _)| r.contains(vec2(x, y))) { + settings.language = code.clone(); + return MenuAction::SettingsChanged; + } + return MenuAction::Consumed; + } + let hit_rect = self.buttons.iter().find(|(r, _)| r.contains(vec2(x, y))).map(|(r, _)| *r); let hit = self.buttons.iter().find(|(r, _)| r.contains(vec2(x, y))).map(|(_, b)| *b); // overwrite and delete need a second click on the same button let confirm = self.confirm.take(); @@ -581,6 +603,13 @@ impl Menu { let from_title = matches!(self.page, Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. })); match hit { None => MenuAction::Consumed, + Some(Btn::LangOpen) => { + self.lang_open = hit_rect; + // start the list so that the language in use is visible + let k = languages().iter().position(|(c, _)| *c == settings.language).unwrap_or(0); + self.lang_scroll = k.saturating_sub(3); + MenuAction::Consumed + } Some(Btn::Continue) => MenuAction::Continue, Some(Btn::Resume) => { self.page = None; @@ -809,7 +838,12 @@ impl Menu { pub fn draw(&mut self, gfx: &Gfx, settings: &Settings, status: Option<&str>, cloud: &Cloud) { self.buttons.clear(); - let Some(page) = self.page else { return }; + self.lang_opts.clear(); + self.cur_lang = settings.language.clone(); + let Some(page) = self.page else { + self.lang_open = None; + return; + }; let sw = sw(); let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, if page == Page::Title { 0.55 } else { 0.45 })); @@ -840,6 +874,9 @@ impl Menu { gfx.text(&esc, x + CW() * 4.0, y + CH() * 3.0, S(), DIM); } } + if let Some(anchor) = self.lang_open { + self.draw_lang_list(gfx, anchor); + } if let Some(st) = status { // long messages wrap onto several lines let maxc = ((sw / CW()) as usize).saturating_sub(8).clamp(10, 70); @@ -865,6 +902,83 @@ impl Menu { } } + // the language dropdown field: flag, the language's own name, an arrow + fn lang_field(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32) { + let (mx, my) = mouse(); + let r = Rect::new(x, y, w, CH() * 2.0); + let hot = r.contains(vec2(mx, my)) || self.lang_open == Some(r); + draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.13, 0.09, 0.08, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { FRAME }); + let code = self.cur_lang.clone(); + let name = languages().into_iter().find(|(c, _)| *c == code).map(|(_, n)| n).unwrap_or_else(|| code.clone()); + self.lang_row(gfx, &code, &name, r, if hot { FRAME_HI } else { TXT }); + gfx.glyph(if self.lang_open == Some(r) { '▲' } else { '▼' }, r.x + r.w - CW() * 1.5, r.y + CH() * 0.5, S(), if hot { FRAME_HI } else { DIM }); + self.buttons.push((r, Btn::LangOpen)); + } + + // a flag and a language name inside a row; returns the x where the text ends + fn lang_row(&self, gfx: &Gfx, code: &str, name: &str, r: Rect, col: Color) -> f32 { + let px = S(); + let fy = r.y + (r.h - flags::FLAG_H as f32 * px) * 0.5; + flags::draw_flag(code, (r.x + CW() * 0.75).floor(), fy.floor(), px); + let tx = r.x + CW() * 0.75 + flags::FLAG_W as f32 * px + CW(); + let maxc = ((r.x + r.w - CW() * 2.0 - tx) / CW()).max(1.0) as usize; + let name: String = name.chars().take(maxc).collect(); + gfx.text(&name, tx, r.y + (r.h - CH()) * 0.5, S(), col); + tx + gfx.measure(&name, S()) + } + + // the open dropdown: all languages under (or, without room, above) the field; scrolls + fn draw_lang_list(&mut self, gfx: &Gfx, a: Rect) { + let (mx, my) = mouse(); + let langs = languages(); + let row_h = CH() * 2.0; + let below = ((sh() - a.y - a.h - CH()) / row_h).floor().max(0.0) as usize; + let above = ((a.y - CH()) / row_h).floor().max(0.0) as usize; + let down = below >= langs.len().min(6) || below >= above; + let rows = langs.len().min(if down { below } else { above }).max(1); + self.lang_rows = rows; + self.lang_scroll = self.lang_scroll.min(langs.len().saturating_sub(rows)); + let w = a.w.max(CW() * 20.0); + let x = a.x.min(sw() - w - CW() * 0.5).max(0.0); + let y0 = if down { a.y + a.h } else { a.y - rows as f32 * row_h }; + draw_rectangle(x, y0, w, rows as f32 * row_h, Color::new(0.06, 0.04, 0.05, 0.98)); + for (k, (code, name)) in langs.iter().enumerate().skip(self.lang_scroll).take(rows) { + let r = Rect::new(x, y0 + (k - self.lang_scroll) as f32 * row_h, w, row_h); + let hot = r.contains(vec2(mx, my)); + let cur = *code == self.cur_lang; + if hot || cur { + draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.2, 0.13, 0.08, 1.0) }); + } + self.lang_row(gfx, code, name, r, if hot || cur { FRAME_HI } else { TXT }); + self.lang_opts.push((r, code.clone())); + } + // more languages than rows: arrows show that the list scrolls + if self.lang_scroll > 0 { + gfx.glyph('▲', x + w - CW() * 1.5, y0 + CH() * 0.5, S(), DIM); + } + if self.lang_scroll + rows < langs.len() { + gfx.glyph('▼', x + w - CW() * 1.5, y0 + rows as f32 * row_h - CH() * 1.5, S(), DIM); + } + draw_rectangle_lines(x, y0, w, rows as f32 * row_h, 2.0, FRAME_HI); + } + + // mouse wheel / swipe over an open dropdown: scrolls its list; true if it took the motion + pub fn lang_wheel(&mut self, d: i32) -> bool { + if self.lang_open.is_none() { + return false; + } + let n = languages().len(); + self.lang_scroll = (self.lang_scroll as i32 + d).clamp(0, n.saturating_sub(self.lang_rows) as i32) as usize; + true + } + + // the language dropdown in a corner of the title and login screens + fn corner_lang_field(&mut self, gfx: &Gfx) { + let w = CW() * 18.0; + self.lang_field(gfx, (sw() - w - CW()).max(0.0), CH(), w); + } + fn draw_title(&mut self, gfx: &Gfx, cloud: &Cloud) { let sw = sw(); let sh = sh(); @@ -899,6 +1013,7 @@ impl Menu { let credit = tr("help.font"); gfx.text(&credit, grid_center(sw, gfx.measure(&credit, SMALL())), sh - CH() * 7.0, SMALL(), DIM); self.draw_license(gfx); + self.corner_lang_field(gfx); } // licence and repository link at the bottom of the screen (title and login) @@ -1086,6 +1201,10 @@ impl Menu { // value with < > buttons let vw = 18.0 * CW(); let vx = x + cols as f32 * CW() - vw - CW() * 3.0; + if it == Item::Language { + self.lang_field(gfx, vx, by, vw); + continue; + } let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0); let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0); button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME); @@ -1568,6 +1687,7 @@ impl Menu { self.buttons.push((br, Btn::DoLogin)); } self.draw_license(gfx); + self.corner_lang_field(gfx); } // Account: change the password, log out; the admin manages the users diff --git a/src/menu/flags.rs b/src/menu/flags.rs new file mode 100644 index 0000000..d2dbafd --- /dev/null +++ b/src/menu/flags.rs @@ -0,0 +1,207 @@ +// Pixel flags for the language dropdown: 12 x 9 pixels each, one letter per pixel +// (see PALETTE). Simplified renditions; the coats of arms are a few pixels only. + +use macroquad::prelude::*; + +pub const FLAG_W: usize = 12; +pub const FLAG_H: usize = 9; + +fn palette(c: char) -> Option { + let rgb = |r: u8, g: u8, b: u8| Some(Color::from_rgba(r, g, b, 255)); + match c { + 'R' => rgb(206, 32, 41), // red + 'W' => rgb(240, 240, 240), // white + 'B' => rgb(0, 57, 166), // blue + 'N' => rgb(1, 33, 105), // navy (Union Jack) + 'Y' => rgb(255, 204, 0), // yellow + 'G' => rgb(0, 146, 70), // green + 'D' => rgb(0, 102, 0), // dark green (Portugal) + 'K' => rgb(16, 16, 16), // black + 'S' => rgb(0, 106, 167), // Swedish blue + 'F' => rgb(0, 47, 108), // Finnish blue + 'L' => rgb(13, 94, 175), // Greek blue + 'O' => rgb(170, 21, 27), // dark red (coats of arms) + _ => None, + } +} + +// three horizontal stripes of three rows each +macro_rules! tricolour { + ($a:literal, $b:literal, $c:literal) => { + [$a, $a, $a, $b, $b, $b, $c, $c, $c] + }; +} + +// the flag of a language code +pub fn flag(code: &str) -> Option<[&'static str; FLAG_H]> { + Some(match code { + "de" => tricolour!("KKKKKKKKKKKK", "RRRRRRRRRRRR", "YYYYYYYYYYYY"), + "nl" => tricolour!("RRRRRRRRRRRR", "WWWWWWWWWWWW", "BBBBBBBBBBBB"), + "ru" => tricolour!("WWWWWWWWWWWW", "BBBBBBBBBBBB", "RRRRRRRRRRRR"), + "hu" => tricolour!("RRRRRRRRRRRR", "WWWWWWWWWWWW", "GGGGGGGGGGGG"), + "it" => ["GGGGWWWWRRRR"; FLAG_H], + "ro" => ["BBBBYYYYRRRR"; FLAG_H], + "en" => [ + "RWNNWRRWNNWR", + "WRRWWRRWWRRW", + "NNWRWRRWRWNN", + "WWWWWRRWWWWW", + "RRRRRRRRRRRR", + "WWWWWRRWWWWW", + "NNWRWRRWRWNN", + "WRRWWRRWWRRW", + "RWNNWRRWNNWR", + ], + "es" => [ + "RRRRRRRRRRRR", + "RRRRRRRRRRRR", + "YYYYYYYYYYYY", + "YYOOYYYYYYYY", + "YYOROYYYYYYY", + "YYOOYYYYYYYY", + "YYYYYYYYYYYY", + "RRRRRRRRRRRR", + "RRRRRRRRRRRR", + ], + "pt" => [ + "DDDDDRRRRRRR", + "DDDDDRRRRRRR", + "DDDDDRRRRRRR", + "DDDYYYRRRRRR", + "DDDYRYRRRRRR", + "DDDYYYRRRRRR", + "DDDDDRRRRRRR", + "DDDDDRRRRRRR", + "DDDDDRRRRRRR", + ], + "da" => [ + "RRRWWRRRRRRR", + "RRRWWRRRRRRR", + "RRRWWRRRRRRR", + "RRRWWRRRRRRR", + "WWWWWWWWWWWW", + "RRRWWRRRRRRR", + "RRRWWRRRRRRR", + "RRRWWRRRRRRR", + "RRRWWRRRRRRR", + ], + "sv" => [ + "SSSYYSSSSSSS", + "SSSYYSSSSSSS", + "SSSYYSSSSSSS", + "SSSYYSSSSSSS", + "YYYYYYYYYYYY", + "SSSYYSSSSSSS", + "SSSYYSSSSSSS", + "SSSYYSSSSSSS", + "SSSYYSSSSSSS", + ], + "fi" => [ + "WWWFFWWWWWWW", + "WWWFFWWWWWWW", + "WWWFFWWWWWWW", + "FFFFFFFFFFFF", + "FFFFFFFFFFFF", + "WWWFFWWWWWWW", + "WWWFFWWWWWWW", + "WWWFFWWWWWWW", + "WWWFFWWWWWWW", + ], + "no" => [ + "RRRWNWRRRRRR", + "RRRWNWRRRRRR", + "RRRWNWRRRRRR", + "WWWWNWWWWWWW", + "NNNNNNNNNNNN", + "WWWWNWWWWWWW", + "RRRWNWRRRRRR", + "RRRWNWRRRRRR", + "RRRWNWRRRRRR", + ], + "hr" => [ + "RRRRRRRRRRRR", + "RRRRRRRRRRRR", + "RRRRRRRRRRRR", + "WWWWRWRWWWWW", + "WWWWWRWRWWWW", + "WWWWRWRWWWWW", + "BBBBWRWRBBBB", + "BBBBBWRBBBBB", + "BBBBBBBBBBBB", + ], + "sr" => [ + "RRRRRRRRRRRR", + "RRRYYRRRRRRR", + "RROWWORRRRRR", + "BBOWWOBBBBBB", + "BBOWWOBBBBBB", + "BBBOOBBBBBBB", + "WWWWWWWWWWWW", + "WWWWWWWWWWWW", + "WWWWWWWWWWWW", + ], + "sk" => [ + "WWWWWWWWWWWW", + "WWWWWWWWWWWW", + "WWRRRRRWWWWW", + "BBRRWRRBBBBB", + "BBRWWWRBBBBB", + "BBRRWRRBBBBB", + "RRRBBBRRRRRR", + "RRRRBRRRRRRR", + "RRRRRRRRRRRR", + ], + "cs" => [ + "BWWWWWWWWWWW", + "BBWWWWWWWWWW", + "BBBWWWWWWWWW", + "BBBBWWWWWWWW", + "BBBBBRRRRRRR", + "BBBBRRRRRRRR", + "BBBRRRRRRRRR", + "BBRRRRRRRRRR", + "BRRRRRRRRRRR", + ], + "el" => [ + "LLWLLWWWWWWW", + "LLWLLLLLLLLL", + "WWWWWWWWWWWW", + "LLWLLLLLLLLL", + "LLWLLWWWWWWW", + "LLLLLLLLLLLL", + "WWWWWWWWWWWW", + "LLLLLLLLLLLL", + "WWWWWWWWWWWW", + ], + _ => return None, + }) +} + +// draws a flag with its top-left corner at (x, y), px screen pixels per flag pixel +pub fn draw_flag(code: &str, x: f32, y: f32, px: f32) { + let rows = flag(code).unwrap_or(["WWWWWWWWWWWW"; FLAG_H]); + for (r, row) in rows.iter().enumerate() { + for (k, ch) in row.chars().enumerate() { + if let Some(c) = palette(ch) { + draw_rectangle(x + k as f32 * px, y + r as f32 * px, px, px, c); + } + } + } + draw_rectangle_lines(x - 1.0, y - 1.0, FLAG_W as f32 * px + 2.0, FLAG_H as f32 * px + 2.0, 1.0, Color::new(0.0, 0.0, 0.0, 0.6)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_language_has_a_complete_flag() { + for code in ["de", "en", "es", "pt", "nl", "da", "no", "sv", "fi", "ru", "hr", "hu", "sr", "ro", "sk", "cs", "it", "el"] { + let f = flag(code).unwrap_or_else(|| panic!("{} has a flag", code)); + for row in f.iter() { + assert_eq!(row.chars().count(), FLAG_W, "{}: {}", code, row); + assert!(row.chars().all(|c| palette(c).is_some()), "{}: {}", code, row); + } + } + } +}