split code into sensible modules
This commit is contained in:
parent
d6990ac8f7
commit
3a9607c4c9
5 changed files with 338 additions and 241 deletions
280
src/main.rs
280
src/main.rs
|
|
@ -1,24 +1,24 @@
|
|||
//! A simple CLI app for displaying rich 24 hour time information for multiple
|
||||
//! people and/or time zones.
|
||||
//! locations.
|
||||
|
||||
mod config;
|
||||
mod message;
|
||||
mod model;
|
||||
mod update;
|
||||
mod view;
|
||||
|
||||
use std::{ffi::OsString, io, time::Duration};
|
||||
|
||||
use chrono::{NaiveTime, TimeDelta, Timelike, Utc, offset::LocalResult};
|
||||
use chrono_tz::Tz;
|
||||
use clap::Parser;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::{
|
||||
DefaultTerminal,
|
||||
prelude::*,
|
||||
symbols::border,
|
||||
widgets::{Block, BorderType::Rounded, Cell, Padding, Row, Table, TableState},
|
||||
};
|
||||
use ratatui_textarea::{Input, TextArea};
|
||||
use unidecode::unidecode;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::DefaultTerminal;
|
||||
|
||||
use crate::config::{Config, Location};
|
||||
use crate::{
|
||||
config::Config,
|
||||
message::Message,
|
||||
model::{FocusState, Model, RunningState},
|
||||
view::view,
|
||||
};
|
||||
|
||||
/// A simple CLI app for displaying rich 24 hour time information for multiple
|
||||
/// people and/or time zones.
|
||||
|
|
@ -40,70 +40,42 @@ fn main() -> color_eyre::Result<()> {
|
|||
/// TUI render loop.
|
||||
fn run(terminal: &mut DefaultTerminal, config: Config) -> color_eyre::Result<()> {
|
||||
let mut model = Model::from_config(config);
|
||||
|
||||
while model.running_state != RunningState::Done {
|
||||
terminal.draw(|f| view(&mut model, f))?;
|
||||
|
||||
// Handle events and map to a Message
|
||||
let mut current_msg = handle_event(&model)?;
|
||||
|
||||
model.update_loop(Some(Message::TimeProgressed))?;
|
||||
terminal.draw(|frame| view(&mut model, frame))?;
|
||||
// Process updates as long as they return a non-None message
|
||||
while current_msg.is_some() {
|
||||
current_msg = update(&mut model, current_msg.unwrap());
|
||||
model.update_loop(handle_event(&model)?)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// App-wide Elm model.
|
||||
#[derive(Debug)]
|
||||
struct Model<'a> {
|
||||
config: Config,
|
||||
table_state: TableState,
|
||||
running_state: RunningState,
|
||||
focus_state: FocusState,
|
||||
filter_textarea: TextArea<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Model<'a> {
|
||||
fn from_config(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
table_state: Default::default(),
|
||||
running_state: Default::default(),
|
||||
focus_state: Default::default(),
|
||||
filter_textarea: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Eq, PartialEq)]
|
||||
enum RunningState {
|
||||
#[default]
|
||||
Running,
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Eq, PartialEq)]
|
||||
enum FocusState {
|
||||
#[default]
|
||||
Table,
|
||||
Filter,
|
||||
}
|
||||
|
||||
enum Message {
|
||||
Quit,
|
||||
FilterOpened,
|
||||
FilterCancelled,
|
||||
FilterUpdated(Input),
|
||||
}
|
||||
|
||||
/// Momentarily block until a crossterm event is detected or the internal
|
||||
/// timeout expires.
|
||||
fn handle_event(model: &Model) -> io::Result<Option<Message>> {
|
||||
Ok(if event::poll(Duration::from_secs(5))? {
|
||||
match event::read()? {
|
||||
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
|
||||
handle_key_event(model, key_event)
|
||||
match (&model.focus_state, key_event.code) {
|
||||
(_, KeyCode::Char('c'))
|
||||
if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
|
||||
{
|
||||
Some(Message::Quit)
|
||||
}
|
||||
(FocusState::Table, KeyCode::Char('q')) => Some(Message::Quit),
|
||||
(FocusState::Table, KeyCode::Char('/')) => Some(Message::FilterOpened),
|
||||
(FocusState::Filter, KeyCode::Esc) => Some(Message::FilterCancelled),
|
||||
// Ignore input that creates newlines: START
|
||||
(FocusState::Filter, KeyCode::Enter) => None,
|
||||
(FocusState::Filter, KeyCode::Char('m'))
|
||||
if key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
// Ignore input that creates newlines: END
|
||||
(FocusState::Filter, _) => Some(Message::FilterUpdated(key_event.into())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
|
@ -113,177 +85,3 @@ fn handle_event(model: &Model) -> io::Result<Option<Message>> {
|
|||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_key_event(model: &Model, key_event: KeyEvent) -> Option<Message> {
|
||||
match (&model.focus_state, key_event.code) {
|
||||
(_, KeyCode::Char('c')) if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(Message::Quit)
|
||||
}
|
||||
(FocusState::Table, KeyCode::Char('q')) => Some(Message::Quit),
|
||||
(FocusState::Table, KeyCode::Char('/')) => Some(Message::FilterOpened),
|
||||
(FocusState::Filter, KeyCode::Esc) | (FocusState::Filter, KeyCode::Enter) => {
|
||||
Some(Message::FilterCancelled)
|
||||
}
|
||||
// Ignore input that creates newlines.
|
||||
(FocusState::Filter, KeyCode::Char('m'))
|
||||
if key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
(FocusState::Filter, _) => Some(Message::FilterUpdated(key_event.into())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Elm update function.
|
||||
fn update(model: &mut Model, msg: Message) -> Option<Message> {
|
||||
match msg {
|
||||
Message::Quit => model.running_state = RunningState::Done,
|
||||
Message::FilterOpened => model.focus_state = FocusState::Filter,
|
||||
Message::FilterCancelled => {
|
||||
model.focus_state = FocusState::Table;
|
||||
model.filter_textarea.clear();
|
||||
}
|
||||
Message::FilterUpdated(input) => {
|
||||
model.filter_textarea.input(input);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Elm render function.
|
||||
fn view(model: &mut Model, frame: &mut Frame) {
|
||||
let title = Line::from(" Team Time Zones ".bold());
|
||||
let instructions = Line::from(vec![
|
||||
" Filter ".into(),
|
||||
"/".blue().bold(),
|
||||
" Quit ".into(),
|
||||
"Q ".blue().bold(),
|
||||
]);
|
||||
let block = Block::bordered()
|
||||
.title(title.centered().fg(Color::Reset))
|
||||
.title_bottom(instructions.centered().fg(Color::Reset))
|
||||
.border_set(border::PLAIN)
|
||||
.border_style(Style::new().fg(Color::DarkGray))
|
||||
.border_type(Rounded)
|
||||
.padding(Padding::horizontal(2));
|
||||
|
||||
let visible_locations: Vec<&Location> = if model.focus_state == FocusState::Filter {
|
||||
let filter_terms: Vec<String> = model
|
||||
.filter_textarea
|
||||
.clone()
|
||||
.into_lines()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split('|')
|
||||
.map(|value| value.trim().to_owned())
|
||||
.collect();
|
||||
model
|
||||
.config
|
||||
.locations
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, location)| {
|
||||
*i == 0
|
||||
|| filter_terms.iter().any(|filter_term| {
|
||||
unidecode(&location.name.to_ascii_lowercase())
|
||||
.contains(&unidecode(&filter_term.to_ascii_lowercase()))
|
||||
})
|
||||
})
|
||||
.map(|(_, value)| value)
|
||||
.collect()
|
||||
} else {
|
||||
model.config.locations.iter().collect()
|
||||
};
|
||||
|
||||
let header = Row::new(
|
||||
visible_locations
|
||||
.iter()
|
||||
.map(|location| location.name.as_str())
|
||||
.collect::<Vec<&str>>(),
|
||||
)
|
||||
.cyan()
|
||||
.bold();
|
||||
let col_widths: Vec<Constraint> = visible_locations
|
||||
.iter()
|
||||
.map(|location| Constraint::Length(clamp_u16(location.name.len(), 5, 32)))
|
||||
.collect();
|
||||
|
||||
let mut rows: Vec<Row> = Vec::with_capacity(24);
|
||||
let home_tz = visible_locations
|
||||
.first()
|
||||
.expect("should always be at least one location loaded and visible")
|
||||
.tz;
|
||||
let now = Utc::now();
|
||||
let mut home_datetime = match now
|
||||
.with_timezone(&home_tz)
|
||||
.with_time(NaiveTime::from_hms_opt(0, 0, 0).expect("00:00:00 is a valid time"))
|
||||
{
|
||||
LocalResult::Single(datetime) | LocalResult::Ambiguous(datetime, _) => datetime,
|
||||
LocalResult::None => {
|
||||
// TODO: display error
|
||||
return;
|
||||
}
|
||||
};
|
||||
for _ in 0..24 {
|
||||
let is_current_time = now.with_timezone(&Tz::UTC) - home_datetime > TimeDelta::zero()
|
||||
&& now.with_timezone(&Tz::UTC) - home_datetime < TimeDelta::minutes(60);
|
||||
rows.push(
|
||||
Row::new(visible_locations.iter().map(|Location { tz, .. }| {
|
||||
let col_datetime = home_datetime.with_timezone(tz);
|
||||
let is_daylight = col_datetime.hour() >= 9 && col_datetime.hour() <= 16;
|
||||
Cell::new(col_datetime.format("%H:%M").to_string()).style(
|
||||
if is_current_time || is_daylight {
|
||||
Style::new()
|
||||
} else {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
},
|
||||
)
|
||||
}))
|
||||
.style(if is_current_time {
|
||||
Style::new().red()
|
||||
} else {
|
||||
Style::new()
|
||||
}),
|
||||
);
|
||||
home_datetime += TimeDelta::hours(1);
|
||||
}
|
||||
|
||||
let table = Table::new(rows, col_widths)
|
||||
.header(header)
|
||||
.block(block)
|
||||
.column_spacing(4);
|
||||
|
||||
let layout = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(if model.focus_state == FocusState::Filter {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
]);
|
||||
let [table_area, filter_area] = layout.areas(frame.area());
|
||||
if model.focus_state == FocusState::Filter {
|
||||
frame.render_widget(&model.filter_textarea, filter_area);
|
||||
}
|
||||
frame.render_stateful_widget(table, table_area, &mut model.table_state);
|
||||
}
|
||||
|
||||
/// Clip a [`usize`]-like value to between two [`u16`]s (inclusive).
|
||||
fn clamp_u16<T>(value: T, min: u16, max: u16) -> u16
|
||||
where
|
||||
T: Into<usize>,
|
||||
{
|
||||
let value: usize = value.into();
|
||||
if value <= min.into() {
|
||||
min
|
||||
} else if value >= max.into() {
|
||||
max
|
||||
} else {
|
||||
value
|
||||
.try_into()
|
||||
.expect("value is known to be unsigned and less than some valid u16")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
src/message.rs
Normal file
10
src/message.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
use ratatui_textarea::Input;
|
||||
|
||||
/// Elm message type.
|
||||
pub(crate) enum Message {
|
||||
Quit,
|
||||
FilterOpened,
|
||||
FilterCancelled,
|
||||
FilterUpdated(Input),
|
||||
TimeProgressed,
|
||||
}
|
||||
101
src/model.rs
Normal file
101
src/model.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
use chrono::NaiveDateTime;
|
||||
use color_eyre::Result;
|
||||
use ratatui::widgets::TableState;
|
||||
use ratatui_textarea::TextArea;
|
||||
use unidecode::unidecode;
|
||||
|
||||
use crate::{
|
||||
config::{Config, Location},
|
||||
message::Message,
|
||||
update::update,
|
||||
};
|
||||
|
||||
/// App-wide Elm model.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Model<'a> {
|
||||
pub(crate) config: Config,
|
||||
pub(crate) table_state: TableState,
|
||||
pub(crate) table_rows: Vec<Vec<CellData>>,
|
||||
pub(crate) running_state: RunningState,
|
||||
pub(crate) focus_state: FocusState,
|
||||
pub(crate) filter_textarea: TextArea<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Model<'a> {
|
||||
/// Create new model from parsed runtime config.
|
||||
pub(crate) fn from_config(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
table_state: Default::default(),
|
||||
table_rows: Default::default(),
|
||||
running_state: Default::default(),
|
||||
focus_state: Default::default(),
|
||||
filter_textarea: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run Elm updates until they produce no further recursive messages.
|
||||
pub(crate) fn update_loop(&mut self, msg: Option<Message>) -> Result<()> {
|
||||
let mut maybe_msg = msg;
|
||||
while let Some(msg) = maybe_msg {
|
||||
maybe_msg = update(self, msg)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute list of visible locations based on filter value.
|
||||
pub(crate) fn get_visible_locations(&self) -> Vec<Location> {
|
||||
if self.focus_state == FocusState::Filter {
|
||||
let filter_terms = self.get_filter_terms();
|
||||
self.config
|
||||
.locations
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, location)| {
|
||||
*i == 0
|
||||
|| filter_terms.iter().any(|filter_term| {
|
||||
unidecode(&location.name.to_ascii_lowercase())
|
||||
.contains(&unidecode(&filter_term.to_ascii_lowercase()))
|
||||
})
|
||||
})
|
||||
.map(|(_, value)| value.clone())
|
||||
.collect()
|
||||
} else {
|
||||
self.config.locations.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filter_terms(&self) -> Vec<String> {
|
||||
self.filter_textarea
|
||||
.clone()
|
||||
.into_lines()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split('|')
|
||||
.map(|value| value.trim().to_owned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum RunningState {
|
||||
#[default]
|
||||
Running,
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum FocusState {
|
||||
#[default]
|
||||
Table,
|
||||
Filter,
|
||||
}
|
||||
|
||||
/// Computed data required to render a timetable cell.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CellData {
|
||||
pub(crate) is_daylight: bool,
|
||||
pub(crate) is_current_time: bool,
|
||||
pub(crate) datetime: NaiveDateTime,
|
||||
}
|
||||
69
src/update.rs
Normal file
69
src/update.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use chrono::{NaiveTime, TimeDelta, Timelike as _, Utc, offset::LocalResult};
|
||||
use chrono_tz::Tz;
|
||||
use color_eyre::{Result, eyre::eyre};
|
||||
|
||||
use crate::{
|
||||
config::Location,
|
||||
message::Message,
|
||||
model::{CellData, FocusState, Model, RunningState},
|
||||
};
|
||||
|
||||
/// Elm update function.
|
||||
pub(crate) fn update(model: &mut Model, msg: Message) -> Result<Option<Message>> {
|
||||
match msg {
|
||||
Message::Quit => model.running_state = RunningState::Done,
|
||||
Message::FilterOpened => model.focus_state = FocusState::Filter,
|
||||
Message::FilterCancelled => {
|
||||
model.focus_state = FocusState::Table;
|
||||
model.filter_textarea.clear();
|
||||
}
|
||||
Message::FilterUpdated(input) => {
|
||||
model.filter_textarea.input(input);
|
||||
}
|
||||
Message::TimeProgressed => {
|
||||
model.table_rows = compute_rows(&model.get_visible_locations())?;
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Compute timetable data based on the model state and current system time.
|
||||
fn compute_rows(visible_locations: &[Location]) -> Result<Vec<Vec<CellData>>> {
|
||||
let mut rows: Vec<Vec<CellData>> = Vec::with_capacity(24);
|
||||
let home_tz = visible_locations
|
||||
.first()
|
||||
.expect("should always be at least one location loaded and visible")
|
||||
.tz;
|
||||
let now = Utc::now();
|
||||
let mut home_datetime = match now
|
||||
.with_timezone(&home_tz)
|
||||
.with_time(NaiveTime::from_hms_opt(0, 0, 0).expect("00:00:00 is a valid time"))
|
||||
{
|
||||
LocalResult::Single(datetime) | LocalResult::Ambiguous(datetime, _) => datetime,
|
||||
LocalResult::None => {
|
||||
return Err(eyre!(
|
||||
"Unable to compute: home time zone has no midnight. Date math is hard!"
|
||||
));
|
||||
}
|
||||
};
|
||||
for _ in 0..24 {
|
||||
let is_current_time = now.with_timezone(&Tz::UTC) - home_datetime > TimeDelta::zero()
|
||||
&& now.with_timezone(&Tz::UTC) - home_datetime < TimeDelta::minutes(60);
|
||||
|
||||
rows.push(
|
||||
visible_locations
|
||||
.iter()
|
||||
.map(|Location { tz, .. }| {
|
||||
let cell_datetime = home_datetime.with_timezone(tz).naive_local();
|
||||
CellData {
|
||||
is_current_time,
|
||||
is_daylight: cell_datetime.hour() >= 9 && cell_datetime.hour() <= 16,
|
||||
datetime: cell_datetime,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
home_datetime += TimeDelta::hours(1);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
119
src/view.rs
Normal file
119
src/view.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use ratatui::widgets::BorderType::Rounded;
|
||||
use ratatui::widgets::{Cell, Padding, Row, Table};
|
||||
use ratatui::{prelude::*, symbols::border, widgets::Block};
|
||||
|
||||
use crate::model::{CellData, FocusState, Model};
|
||||
|
||||
/// Elm render function.
|
||||
pub(crate) fn view(model: &mut Model, frame: &mut Frame) {
|
||||
let title = Line::from(" Team Time Zones ".bold());
|
||||
let instructions = Line::from(match model.focus_state {
|
||||
FocusState::Table => vec![
|
||||
" Filter ".into(),
|
||||
"/".blue().bold(),
|
||||
" Quit ".into(),
|
||||
"Q ".blue().bold(),
|
||||
],
|
||||
FocusState::Filter => vec![" Reset ".into(), "<Esc>".blue().bold(), " ".into()],
|
||||
});
|
||||
let main_block = Block::bordered()
|
||||
.title(title.centered().fg(Color::Reset))
|
||||
.title_bottom(instructions.centered().fg(Color::Reset))
|
||||
.border_set(border::PLAIN)
|
||||
.border_style(Style::new().fg(Color::DarkGray))
|
||||
.border_type(Rounded);
|
||||
frame.render_widget(&main_block, frame.area());
|
||||
|
||||
let visible_locations = model.get_visible_locations();
|
||||
let header = Row::new(
|
||||
visible_locations
|
||||
.iter()
|
||||
.map(|location| location.name.as_str())
|
||||
.collect::<Vec<&str>>(),
|
||||
)
|
||||
.cyan()
|
||||
.bold();
|
||||
let col_widths: Vec<Constraint> = visible_locations
|
||||
.iter()
|
||||
.map(|location| Constraint::Length(clamp_u16(location.name.len(), 5, 32)))
|
||||
.collect();
|
||||
|
||||
// TODO: Figure out a good way to refactor this into a distinct view
|
||||
// function without offending the borrow checker.
|
||||
let table = Table::new(
|
||||
model
|
||||
.table_rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
Row::new(
|
||||
row.iter()
|
||||
.map(
|
||||
|&CellData {
|
||||
is_current_time,
|
||||
is_daylight,
|
||||
datetime,
|
||||
}| {
|
||||
Cell::new(datetime.format("%H:%M").to_string()).style(
|
||||
if is_current_time {
|
||||
Style::new().red()
|
||||
} else if !is_daylight {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
} else {
|
||||
Style::new()
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
.collect::<Vec<Cell>>(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<Row>>(),
|
||||
col_widths,
|
||||
)
|
||||
.header(header)
|
||||
.column_spacing(4)
|
||||
.block(Block::new().padding(Padding::horizontal(2)));
|
||||
|
||||
let [table_area, filter_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(if model.focus_state == FocusState::Filter {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
])
|
||||
.areas(main_block.inner(frame.area()));
|
||||
if model.focus_state == FocusState::Filter {
|
||||
const FILTER_LABEL_TEXT: &str = "Filter: ";
|
||||
let [filter_label_area, filter_input_area] = Layout::horizontal([
|
||||
Constraint::Length(
|
||||
FILTER_LABEL_TEXT
|
||||
.len()
|
||||
.try_into()
|
||||
.expect("filter label is of static, reasonable length"),
|
||||
),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(filter_area);
|
||||
frame.render_widget(FILTER_LABEL_TEXT.dim(), filter_label_area);
|
||||
frame.render_widget(&model.filter_textarea, filter_input_area);
|
||||
}
|
||||
frame.render_stateful_widget(table, table_area, &mut model.table_state);
|
||||
}
|
||||
|
||||
/// Clip a [`usize`]-like value to between two [`u16`]s (inclusive).
|
||||
fn clamp_u16<T>(value: T, min: u16, max: u16) -> u16
|
||||
where
|
||||
T: Into<usize>,
|
||||
{
|
||||
let value: usize = value.into();
|
||||
if value <= min.into() {
|
||||
min
|
||||
} else if value >= max.into() {
|
||||
max
|
||||
} else {
|
||||
value
|
||||
.try_into()
|
||||
.expect("value is known to be unsigned and less than some valid u16")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue