refactor(ui): resolve clippy pedantic lints

Replace lossy casts with checked tile/canvas helpers, use map_or_else and
infallible conversions, and switch the build script to assert!.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-11 21:23:50 +02:00
parent 5a1384cc21
commit 9fd93a8534
5 changed files with 81 additions and 57 deletions

View File

@ -35,11 +35,10 @@ fn main() {
let status = cargo let status = cargo
.status() .status()
.expect("failed to run cargo for the ui crate"); .expect("failed to run cargo for the ui crate");
if !status.success() { assert!(
panic!( status.success(),
"building ui/ failed (see the cargo output above); the wasm32-unknown-unknown target must be installed: rustup target add wasm32-unknown-unknown" "building ui/ failed (see the cargo output above); the wasm32-unknown-unknown target must be installed: rustup target add wasm32-unknown-unknown"
); );
}
let profile = if release { "release" } else { "debug" }; let profile = if release { "release" } else { "debug" };
let wasm = target_dir let wasm = target_dir

View File

@ -24,7 +24,7 @@ pub fn connect(on_state: impl Fn(String) + 'static) {
"ws" "ws"
}; };
let Ok(host) = loc.host() else { return }; let Ok(host) = loc.host() else { return };
open(format!("{proto}://{host}/ws"), on_state.clone()); open(&format!("{proto}://{host}/ws"), on_state.clone());
spawn_local(async move { spawn_local(async move {
loop { loop {
@ -45,8 +45,8 @@ pub fn connect(on_state: impl Fn(String) + 'static) {
}); });
} }
fn open(url: String, on_state: Rc<dyn Fn(String)>) { fn open(url: &str, on_state: Rc<dyn Fn(String)>) {
let Ok(ws) = WebSocket::new(&url) else { return }; let Ok(ws) = WebSocket::new(url) else { return };
let cb = on_state.clone(); let cb = on_state.clone();
let onmessage = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| { let onmessage = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(text) = e.data().as_string() { if let Some(text) = e.data().as_string() {
@ -56,13 +56,13 @@ fn open(url: String, on_state: Rc<dyn Fn(String)>) {
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onmessage.forget(); onmessage.forget();
let url2 = url.clone(); let url = url.to_string();
let onclose = Closure::<dyn FnMut()>::new(move || { let onclose = Closure::<dyn FnMut()>::new(move || {
SOCKET.with(|s| *s.borrow_mut() = None); SOCKET.with(|s| *s.borrow_mut() = None);
let (url, on_state) = (url2.clone(), on_state.clone()); let (url, on_state) = (url.clone(), on_state.clone());
spawn_local(async move { spawn_local(async move {
TimeoutFuture::new(1000).await; TimeoutFuture::new(1000).await;
open(url, on_state); open(&url, on_state);
}); });
}); });
ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));

View File

@ -1,4 +1,4 @@
use crate::model::{Sel, State, T, Terrain}; use crate::model::{Sel, State, T, Terrain, canvas_len, tile_offset};
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement}; use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
@ -7,8 +7,8 @@ const COLORS: [&str; 7] = [
]; ];
pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>) { pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>) {
canvas.set_width((t.w as f64 * T) as u32); canvas.set_width(canvas_len(t.w));
canvas.set_height((t.h as f64 * T) as u32); canvas.set_height(canvas_len(t.h));
let Some(ctx) = canvas let Some(ctx) = canvas
.get_context("2d") .get_context("2d")
.ok() .ok()
@ -21,7 +21,7 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
for y in 0..t.h { for y in 0..t.h {
for x in 0..t.w { for x in 0..t.w {
ctx.set_fill_style_str(COLORS[t.kind(x, y)]); ctx.set_fill_style_str(COLORS[t.kind(x, y)]);
ctx.fill_rect(x as f64 * T, y as f64 * T, T, T); ctx.fill_rect(tile_offset(x), tile_offset(y), T, T);
} }
} }
@ -29,8 +29,8 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
ctx.set_fill_style_str(color); ctx.set_fill_style_str(color);
ctx.begin_path(); ctx.begin_path();
let _ = ctx.arc( let _ = ctx.arc(
x as f64 * T + T / 2.0, tile_offset(x) + T / 2.0,
y as f64 * T + T / 2.0, tile_offset(y) + T / 2.0,
r, r,
0.0, 0.0,
std::f64::consts::TAU, std::f64::consts::TAU,
@ -44,16 +44,21 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
2 => dot(r.0, r.1, 2.0, "#e85a8a"), 2 => dot(r.0, r.1, 2.0, "#e85a8a"),
_ => { _ => {
ctx.set_fill_style_str("#666"); ctx.set_fill_style_str("#666");
ctx.fill_rect(r.0 as f64 * T + 2.0, r.1 as f64 * T + 2.0, 6.0, 6.0); ctx.fill_rect(tile_offset(r.0) + 2.0, tile_offset(r.1) + 2.0, 6.0, 6.0);
} }
} }
} }
ctx.set_fill_style_str("#7a4a22"); ctx.set_fill_style_str("#7a4a22");
for h in &s.houses { for h in &s.houses {
ctx.fill_rect(h.0 as f64 * T + 1.0, h.1 as f64 * T + 1.0, T - 2.0, T - 2.0); ctx.fill_rect(
tile_offset(h.0) + 1.0,
tile_offset(h.1) + 1.0,
T - 2.0,
T - 2.0,
);
} }
ctx.set_fill_style_str("#b05a2a"); ctx.set_fill_style_str("#b05a2a");
ctx.fill_rect(s.home.0 as f64 * T, s.home.1 as f64 * T, T, T); ctx.fill_rect(tile_offset(s.home.0), tile_offset(s.home.1), T, T);
for a in &s.rabbits { for a in &s.rabbits {
dot(a.0, a.1, 2.5, "#fff"); dot(a.0, a.1, 2.5, "#fff");
} }
@ -65,17 +70,17 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
} }
ctx.set_fill_style_str(&format!("rgba(5,10,50,{})", (1.0 - s.daylight) * 0.65)); ctx.set_fill_style_str(&format!("rgba(5,10,50,{})", (1.0 - s.daylight) * 0.65));
ctx.fill_rect(0.0, 0.0, t.w as f64 * T, t.h as f64 * T); ctx.fill_rect(0.0, 0.0, tile_offset(t.w), tile_offset(t.h));
if let Some(sel) = sel { if let Some(sel) = sel {
let (x0, y0, x1, y1) = sel.rect(); let (x0, y0, x1, y1) = sel.rect();
ctx.set_stroke_style_str("#fff"); ctx.set_stroke_style_str("#fff");
ctx.set_line_width(2.0); ctx.set_line_width(2.0);
ctx.stroke_rect( ctx.stroke_rect(
x0 as f64 * T, tile_offset(x0),
y0 as f64 * T, tile_offset(y0),
(x1 - x0 + 1) as f64 * T, tile_offset(x1 - x0 + 1),
(y1 - y0 + 1) as f64 * T, tile_offset(y1 - y0 + 1),
); );
} }
} }

View File

@ -6,7 +6,7 @@ mod model;
use leptos::mount::mount_to_body; use leptos::mount::mount_to_body;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use model::{Person, Sel, State, T, Terrain}; use model::{Person, Sel, State, Terrain, tile_at};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)] #[wasm_bindgen(start)]
@ -53,7 +53,7 @@ fn App() -> impl IntoView {
if let (Some(c), Some(s), Some(t)) = (canvas.get(), s.as_ref(), t.as_ref()) { if let (Some(c), Some(s), Some(t)) = (canvas.get(), s.as_ref(), t.as_ref()) {
canvas::draw(&c, t, s, sel); canvas::draw(&c, t, s, sel);
} }
}) });
}); });
}); });
@ -62,17 +62,14 @@ fn App() -> impl IntoView {
// Map through the displayed size so CSS scaling or zoom can't skew the tile. // Map through the displayed size so CSS scaling or zoom can't skew the tile.
let r = c.get_bounding_client_rect(); let r = c.get_bounding_client_rect();
let (sx, sy) = ( let (sx, sy) = (
c.width() as f64 / r.width().max(1.0), f64::from(c.width()) / r.width().max(1.0),
c.height() as f64 / r.height().max(1.0), f64::from(c.height()) / r.height().max(1.0),
); );
let px = (ev.client_x() as f64 - r.left()) * sx; let px = (f64::from(ev.client_x()) - r.left()) * sx;
let py = (ev.client_y() as f64 - r.top()) * sy; let py = (f64::from(ev.client_y()) - r.top()) * sy;
terrain.with_untracked(|t| { terrain.with_untracked(|t| {
t.as_ref().map(|t| { t.as_ref()
let x = ((px / T).max(0.0) as usize).min(t.w - 1); .map(|t| (tile_at(px, t.w - 1), tile_at(py, t.h - 1)))
let y = ((py / T).max(0.0) as usize).min(t.h - 1);
(x, y)
})
}) })
}; };
@ -149,10 +146,10 @@ fn App() -> impl IntoView {
<div class="side"> <div class="side">
<h3>"World"</h3> <h3>"World"</h3>
{move || state.with(|s| s.as_ref().map(|s| { {move || state.with(|s| s.as_ref().map(|s| {
let (h, m) = (s.hour.floor() as u32, ((s.hour % 1.0) * 60.0) as u32); let (h, m) = (s.hour.floor(), (s.hour.fract() * 60.0).floor());
view! { view! {
<div>{format!( <div>{format!(
"Year {}, month {}, week {}, day {}, {h:02}:{m:02}", "Year {}, month {}, week {}, day {}, {h:02.0}:{m:02.0}",
s.year, s.month, s.week, s.date_day s.year, s.month, s.week, s.date_day
)}</div> )}</div>
<div>{format!("People: {}", s.people.len())}</div> <div>{format!("People: {}", s.people.len())}</div>
@ -181,7 +178,7 @@ fn App() -> impl IntoView {
}))} }))}
<div> <div>
<button on:click=move |_| { <button on:click=move |_| {
api::edit(format!("pause {}", if paused() { 0 } else { 1 })) api::edit(format!("pause {}", u8::from(!paused())));
}>{move || if paused() { "Resume" } else { "Pause" }}</button> }>{move || if paused() { "Resume" } else { "Pause" }}</button>
" Speed " " Speed "
<select <select
@ -278,13 +275,14 @@ fn App() -> impl IntoView {
<input <input
type="number" type="number"
min="1" min="1"
prop:max=move || state.with(|s| s.as_ref().map(|s| { prop:max=move || state.with(|s| s.as_ref().map_or_else(
match tool.get().as_str() { || "100".to_string(),
|s| match tool.get().as_str() {
"energy" => s.limits.animal_energy.to_string(), "energy" => s.limits.animal_energy.to_string(),
"spawn" => s.limits.spawn.to_string(), "spawn" => s.limits.spawn.to_string(),
_ => s.limits.resource.to_string(), _ => s.limits.resource.to_string(),
} },
}).unwrap_or_else(|| "100".to_string())) ))
title="amount / energy" title="amount / energy"
prop:value=move || amount.get() prop:value=move || amount.get()
on:input=move |e| amount.set(event_target_value(&e)) on:input=move |e| amount.set(event_target_value(&e))
@ -398,12 +396,12 @@ fn StageFertility(state: RwSignal<Option<State>>, stage: String) -> impl IntoVie
fn PersonEditor(p: Person, state: RwSignal<Option<State>>) -> impl IntoView { fn PersonEditor(p: Person, state: RwSignal<Option<State>>) -> impl IntoView {
let id = p.0; let id = p.0;
let fields = [ let fields = [
("hunger", p.4 as u32), ("hunger", format!("{:.0}", p.4.trunc())),
("food", p.5), ("food", p.5.to_string()),
("wood", p.6), ("wood", p.6.to_string()),
("stone", p.7), ("stone", p.7.to_string()),
] ]
.map(|(k, v)| (k, RwSignal::new(v.to_string()))); .map(|(k, v)| (k, RwSignal::new(v)));
view! { view! {
<div> <div>
{format!("Edit #{id}: ")} {format!("Edit #{id}: ")}
@ -416,13 +414,14 @@ fn PersonEditor(p: Person, state: RwSignal<Option<State>>) -> impl IntoView {
<input <input
type="number" type="number"
min="0" min="0"
prop:max=move || state.with(|s| s.as_ref().map(|s| { prop:max=move || state.with(|s| s.as_ref().map_or_else(
if k == "hunger" { || "99".to_string(),
|s| if k == "hunger" {
s.limits.hunger.to_string() s.limits.hunger.to_string()
} else { } else {
s.limits.person_inventory.to_string() s.limits.person_inventory.to_string()
} },
}).unwrap_or_else(|| "99".to_string())) ))
prop:value=move || s.get() prop:value=move || s.get()
on:input=move |e| s.set(event_target_value(&e)) on:input=move |e| s.set(event_target_value(&e))
/> />
@ -472,8 +471,10 @@ fn StockEditor(state: RwSignal<Option<State>>) -> impl IntoView {
type="number" type="number"
min="0" min="0"
prop:max=move || state.with(|s| { prop:max=move || state.with(|s| {
s.as_ref().map(|s| s.limits.stock.to_string()) s.as_ref().map_or_else(
.unwrap_or_else(|| "9999".to_string()) || "9999".to_string(),
|s| s.limits.stock.to_string(),
)
}) })
prop:value=move || s.get() prop:value=move || s.get()
on:input=move |e| s.set(event_target_value(&e)) on:input=move |e| s.set(event_target_value(&e))

View File

@ -1,6 +1,25 @@
use serde::Deserialize; use serde::Deserialize;
pub const T: f64 = 10.0; /// Tile size in canvas pixels.
pub const TILE_PX: u32 = 10;
pub const T: f64 = TILE_PX as f64;
/// Canvas offset in pixels of tile coordinate `n`.
pub fn tile_offset(n: usize) -> f64 {
f64::from(u32::try_from(n).unwrap_or(u32::MAX)) * T
}
/// Canvas length in pixels of `n` tiles.
pub fn canvas_len(n: usize) -> u32 {
u32::try_from(n).map_or(u32::MAX, |n| n.saturating_mul(TILE_PX))
}
/// Tile index under canvas pixel offset `px`, clamped to `0..=max`.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn tile_at(px: f64, max: usize) -> usize {
// Float-to-int `as` saturates and maps NaN to 0, so negative or huge offsets stay in range.
((px / T) as usize).min(max)
}
pub const TERRAIN: [&str; 7] = [ pub const TERRAIN: [&str; 7] = [
"Deep water", "Deep water",
"Shallow water", "Shallow water",
@ -114,8 +133,8 @@ impl Calendar {
return Some(format!("stage {stage} fertile {}", u8::from(value != 0))); return Some(format!("stage {stage} fertile {}", u8::from(value != 0)));
} }
let (min_days, max_days) = match bound { let (min_days, max_days) = match bound {
"min_days" => (value.min(u32::MAX as u64) as u32, range.max_days), "min_days" => (u32::try_from(value).unwrap_or(u32::MAX), range.max_days),
"max_days" => (range.min_days, value.min(u32::MAX as u64) as u32), "max_days" => (range.min_days, u32::try_from(value).unwrap_or(u32::MAX)),
_ => return None, _ => return None,
}; };
Some(format!("stage {stage} {min_days} {max_days}")) Some(format!("stage {stage} {min_days} {max_days}"))