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
.status()
.expect("failed to run cargo for the ui crate");
if !status.success() {
panic!(
"building ui/ failed (see the cargo output above); the wasm32-unknown-unknown target must be installed: rustup target add wasm32-unknown-unknown"
);
}
assert!(
status.success(),
"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 wasm = target_dir

View File

@ -24,7 +24,7 @@ pub fn connect(on_state: impl Fn(String) + 'static) {
"ws"
};
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 {
loop {
@ -45,8 +45,8 @@ pub fn connect(on_state: impl Fn(String) + 'static) {
});
}
fn open(url: String, on_state: Rc<dyn Fn(String)>) {
let Ok(ws) = WebSocket::new(&url) else { return };
fn open(url: &str, on_state: Rc<dyn Fn(String)>) {
let Ok(ws) = WebSocket::new(url) else { return };
let cb = on_state.clone();
let onmessage = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
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()));
onmessage.forget();
let url2 = url.clone();
let url = url.to_string();
let onclose = Closure::<dyn FnMut()>::new(move || {
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 {
TimeoutFuture::new(1000).await;
open(url, on_state);
open(&url, on_state);
});
});
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 web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
@ -7,8 +7,8 @@ const COLORS: [&str; 7] = [
];
pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>) {
canvas.set_width((t.w as f64 * T) as u32);
canvas.set_height((t.h as f64 * T) as u32);
canvas.set_width(canvas_len(t.w));
canvas.set_height(canvas_len(t.h));
let Some(ctx) = canvas
.get_context("2d")
.ok()
@ -21,7 +21,7 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
for y in 0..t.h {
for x in 0..t.w {
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.begin_path();
let _ = ctx.arc(
x as f64 * T + T / 2.0,
y as f64 * T + T / 2.0,
tile_offset(x) + T / 2.0,
tile_offset(y) + T / 2.0,
r,
0.0,
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"),
_ => {
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");
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.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 {
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.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 {
let (x0, y0, x1, y1) = sel.rect();
ctx.set_stroke_style_str("#fff");
ctx.set_line_width(2.0);
ctx.stroke_rect(
x0 as f64 * T,
y0 as f64 * T,
(x1 - x0 + 1) as f64 * T,
(y1 - y0 + 1) as f64 * T,
tile_offset(x0),
tile_offset(y0),
tile_offset(x1 - x0 + 1),
tile_offset(y1 - y0 + 1),
);
}
}

View File

@ -6,7 +6,7 @@ mod model;
use leptos::mount::mount_to_body;
use leptos::prelude::*;
use leptos::task::spawn_local;
use model::{Person, Sel, State, T, Terrain};
use model::{Person, Sel, State, Terrain, tile_at};
use wasm_bindgen::prelude::*;
#[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()) {
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.
let r = c.get_bounding_client_rect();
let (sx, sy) = (
c.width() as f64 / r.width().max(1.0),
c.height() as f64 / r.height().max(1.0),
f64::from(c.width()) / r.width().max(1.0),
f64::from(c.height()) / r.height().max(1.0),
);
let px = (ev.client_x() as f64 - r.left()) * sx;
let py = (ev.client_y() as f64 - r.top()) * sy;
let px = (f64::from(ev.client_x()) - r.left()) * sx;
let py = (f64::from(ev.client_y()) - r.top()) * sy;
terrain.with_untracked(|t| {
t.as_ref().map(|t| {
let x = ((px / T).max(0.0) as usize).min(t.w - 1);
let y = ((py / T).max(0.0) as usize).min(t.h - 1);
(x, y)
})
t.as_ref()
.map(|t| (tile_at(px, t.w - 1), tile_at(py, t.h - 1)))
})
};
@ -149,10 +146,10 @@ fn App() -> impl IntoView {
<div class="side">
<h3>"World"</h3>
{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! {
<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
)}</div>
<div>{format!("People: {}", s.people.len())}</div>
@ -181,7 +178,7 @@ fn App() -> impl IntoView {
}))}
<div>
<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>
" Speed "
<select
@ -278,13 +275,14 @@ fn App() -> impl IntoView {
<input
type="number"
min="1"
prop:max=move || state.with(|s| s.as_ref().map(|s| {
match tool.get().as_str() {
prop:max=move || state.with(|s| s.as_ref().map_or_else(
|| "100".to_string(),
|s| match tool.get().as_str() {
"energy" => s.limits.animal_energy.to_string(),
"spawn" => s.limits.spawn.to_string(),
_ => s.limits.resource.to_string(),
}
}).unwrap_or_else(|| "100".to_string()))
},
))
title="amount / energy"
prop:value=move || amount.get()
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 {
let id = p.0;
let fields = [
("hunger", p.4 as u32),
("food", p.5),
("wood", p.6),
("stone", p.7),
("hunger", format!("{:.0}", p.4.trunc())),
("food", p.5.to_string()),
("wood", p.6.to_string()),
("stone", p.7.to_string()),
]
.map(|(k, v)| (k, RwSignal::new(v.to_string())));
.map(|(k, v)| (k, RwSignal::new(v)));
view! {
<div>
{format!("Edit #{id}: ")}
@ -416,13 +414,14 @@ fn PersonEditor(p: Person, state: RwSignal<Option<State>>) -> impl IntoView {
<input
type="number"
min="0"
prop:max=move || state.with(|s| s.as_ref().map(|s| {
if k == "hunger" {
prop:max=move || state.with(|s| s.as_ref().map_or_else(
|| "99".to_string(),
|s| if k == "hunger" {
s.limits.hunger.to_string()
} else {
s.limits.person_inventory.to_string()
}
}).unwrap_or_else(|| "99".to_string()))
},
))
prop:value=move || s.get()
on:input=move |e| s.set(event_target_value(&e))
/>
@ -472,8 +471,10 @@ fn StockEditor(state: RwSignal<Option<State>>) -> impl IntoView {
type="number"
min="0"
prop:max=move || state.with(|s| {
s.as_ref().map(|s| s.limits.stock.to_string())
.unwrap_or_else(|| "9999".to_string())
s.as_ref().map_or_else(
|| "9999".to_string(),
|s| s.limits.stock.to_string(),
)
})
prop:value=move || s.get()
on:input=move |e| s.set(event_target_value(&e))

View File

@ -1,6 +1,25 @@
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] = [
"Deep water",
"Shallow water",
@ -114,8 +133,8 @@ impl Calendar {
return Some(format!("stage {stage} fertile {}", u8::from(value != 0)));
}
let (min_days, max_days) = match bound {
"min_days" => (value.min(u32::MAX as u64) as u32, range.max_days),
"max_days" => (range.min_days, value.min(u32::MAX as u64) as u32),
"min_days" => (u32::try_from(value).unwrap_or(u32::MAX), range.max_days),
"max_days" => (range.min_days, u32::try_from(value).unwrap_or(u32::MAX)),
_ => return None,
};
Some(format!("stage {stage} {min_days} {max_days}"))