use denise::Pen;
use denise::theme::{AA_LARGE, contrast_x100, derive_content};
use denise::{Color, Point, Rect, Role, Size, Theme};
use denise_text::{TextEngine, TextStyle};
use crate::widget::VisualState;
const HOVER_MIX: u8 = 24;
const PRESS_MIX: u8 = 64;
const MUTE: u8 = 64;
pub(crate) fn muted(surface: Color, content: Color) -> Color {
let muted = content.mix(surface, MUTE);
if contrast_x100(surface, muted) >= AA_LARGE {
muted
} else {
content
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Orientation {
#[default]
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Align {
#[default]
Start,
Center,
End,
}
impl Align {
#[inline]
pub const fn offset(self, available: i32, content: i32) -> i32 {
match self {
Align::Start => 0,
Align::Center => (available - content) / 2,
Align::End => available - content,
}
}
}
pub(crate) fn interactive_pair(theme: &Theme, role: Role, state: VisualState) -> (Color, Color) {
let (background, content) = theme.pair(role);
if state.contains(VisualState::DISABLED) {
let background = theme.color(Role::Base200);
return (background, derive_content(background, AA_LARGE));
}
if state.contains(VisualState::PRESSED) {
return (background.mix(content, PRESS_MIX), content);
}
if state.contains(VisualState::HOVERED) {
return (background.mix(content, HOVER_MIX), content);
}
(background, content)
}
pub(crate) fn focus_ring(theme: &Theme, bounds: Rect, radius: i32, canvas: &mut Pen<'_>) {
canvas.stroke_rounded_rect(
bounds.inflate(-1),
(radius - 1).max(0),
2,
theme.color(Role::Accent),
);
}
pub(crate) fn draw_aligned(
canvas: &mut Pen<'_>,
engine: &mut TextEngine,
style: TextStyle,
bounds: Rect,
align: (Align, Align),
text: &str,
color: Color,
) -> Size {
let extent = engine.measure(style, text);
let at = Point::new(
bounds.x + align.0.offset(bounds.width, extent.width as i32),
bounds.y + align.1.offset(bounds.height, extent.height as i32),
);
engine.draw(canvas, style, at, text, color);
extent
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alignment_offsets() {
assert_eq!(Align::Start.offset(100, 20), 0);
assert_eq!(Align::Center.offset(100, 20), 40);
assert_eq!(Align::End.offset(100, 20), 80);
assert_eq!(Align::Center.offset(20, 100), -40);
}
#[test]
fn every_state_keeps_the_pair_readable() {
for theme in Theme::BUILT_IN {
for role in [Role::Primary, Role::Secondary, Role::Accent, Role::Error] {
for state in [
VisualState::NONE,
VisualState::HOVERED,
VisualState::PRESSED,
VisualState::DISABLED,
] {
let (background, content) = interactive_pair(&theme, role, state);
let ratio = denise::theme::contrast_x100(background, content);
assert!(
ratio >= AA_LARGE,
"{} {role:?} {state:?} is {ratio} against a floor of {AA_LARGE}",
theme.name
);
}
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RowKind {
Resting,
Hovered,
Selected,
}
pub(crate) fn row_colors(
theme: &Theme,
state: VisualState,
role: Role,
kind: RowKind,
enabled: bool,
) -> (Color, Color) {
let state = state
.set(VisualState::HOVERED, false)
.set(VisualState::PRESSED, false);
let (surface, content) = match kind {
RowKind::Selected if state.contains(VisualState::DISABLED) => theme.pair(Role::Base300),
RowKind::Selected => interactive_pair(theme, role, state),
RowKind::Hovered => interactive_pair(theme, Role::Base200, state),
RowKind::Resting => interactive_pair(theme, Role::Base100, state),
};
if enabled {
(surface, content)
} else {
(surface, muted(surface, content))
}
}
pub(crate) fn hovered_row(state: VisualState, remembered: Option<usize>) -> Option<usize> {
if state.contains(VisualState::HOVERED) {
remembered
} else {
None
}
}
pub(crate) const DOUBLE_CLICK_MS: u64 = 400;
pub(crate) const CARET_BLINKS_FOR_MS: u64 = 10_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Intent {
Select,
Activate,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct ClickPair {
last: Option<(usize, u64)>,
}
impl ClickPair {
pub(crate) fn classify(&mut self, row: usize, at_ms: u64, single_click: bool) -> Intent {
if single_click {
return Intent::Activate;
}
let pair = self
.last
.is_some_and(|(r, at)| r == row && at_ms >= at && at_ms - at <= DOUBLE_CLICK_MS);
if pair {
self.last = None;
Intent::Activate
} else {
self.last = Some((row, at_ms));
Intent::Select
}
}
pub(crate) fn forget(&mut self) {
self.last = None;
}
}
pub(crate) fn columns(row: Rect, pad: i32, leading: i32, trailing: i32) -> (Rect, Rect, Rect) {
let left = row.x + pad;
let right = (row.right() - pad).max(left);
let box_of =
|start: i32, end: i32| Rect::from_edges(start, row.y, end.max(start), row.bottom());
let leading_box = box_of(left, (left + leading).min(right));
let trailing_box = box_of((right - trailing).max(left), right);
let start = if leading > 0 {
(leading_box.right() + pad).clamp(left, right)
} else {
left
};
let end = if trailing > 0 {
(trailing_box.x - pad).clamp(left, right)
} else {
right
};
(leading_box, box_of(start, end), trailing_box)
}