use concinnity_core::ecs::Ref;
use std::collections::{HashMap, HashSet};
use concinnity_core::components::{FrameInput, SettingVerb, SpriteFit, TextLabel, UiAction};
use concinnity_core::ecs::asset_id::AssetId;
use concinnity_core::ecs::{FrameVec, PipelineContext, StepResult};
use concinnity_core::gfx::overlay::OverlayTransform;
use concinnity_core::settings::SettingKey;
use super::intent::UiIntent;
use super::{
OpenRequest, UiInputSystem, fire_action, point_in_rect, region_covers_canvas, region_rect,
set_label_style,
};
pub(super) struct RegionFrame<'a> {
active_screen: Option<AssetId>,
thumb_active: bool,
overlay: OverlayTransform,
overlay_bottom: OverlayTransform,
overlay_cover: OverlayTransform,
panel_bands: FrameVec<'a, [f32; 4]>,
}
impl<'a> RegionFrame<'a> {
pub(super) fn new(
viewport: [f32; 2],
active_screen: Option<AssetId>,
thumb_active: bool,
overlay: OverlayTransform,
panel_bands: FrameVec<'a, [f32; 4]>,
) -> Self {
Self {
active_screen,
thumb_active,
overlay,
overlay_bottom: OverlayTransform::bottom_anchored_from_viewport(viewport),
overlay_cover: OverlayTransform::cover_from_viewport(viewport),
panel_bands,
}
}
fn cursor_for(&self, screen: Option<AssetId>, fit: SpriteFit, mx: f32, my: f32) -> (f32, f32) {
if screen.is_none() {
return (mx, my);
}
match fit {
SpriteFit::Bottom => self.overlay_bottom.inverse(mx, my),
SpriteFit::Cover => self.overlay_cover.inverse(mx, my),
SpriteFit::Fit => self.overlay.inverse(mx, my),
}
}
}
#[derive(Default)]
pub(super) struct RegionOutcome {
pub(super) toggle_group: Option<usize>,
pub(super) start_capture: Option<(SettingKey, Option<AssetId>)>,
pub(super) start_open: Option<OpenRequest>,
pub(super) fired: Option<StepResult>,
}
#[derive(Debug, Default, Clone, Copy)]
struct RegionGate {
thumb_active: bool,
slider: bool,
pad_focused: bool,
screen_matches: bool,
collapsed_row: bool,
disabled: bool,
follow_inert: bool,
}
fn region_inert(gate: RegionGate) -> bool {
gate.thumb_active
|| (gate.slider && !gate.pad_focused)
|| !gate.screen_matches
|| gate.collapsed_row
|| gate.disabled
|| gate.follow_inert
}
fn follow_sync(
follow: Option<(AssetId, f32)>,
labels: &HashMap<AssetId, (f32, bool)>,
) -> (Option<f32>, bool) {
let Some((label_id, offset)) = follow else {
return (None, false);
};
match labels.get(&label_id) {
Some(&(ly, empty)) => (Some(ly + offset), empty),
None => (None, true),
}
}
pub(super) fn setting_row_disabled(
disabled_rows: &HashSet<SettingKey>,
action: Option<&UiAction>,
) -> bool {
matches!(action, Some(UiAction::Setting { key, .. }) if disabled_rows.contains(key))
}
fn hover_and_fire(
has_focus: bool,
pad_focused: bool,
mouse_hovered: bool,
clicked: bool,
confirm: bool,
fallback_fire: bool,
) -> (bool, bool) {
if has_focus {
(pad_focused, pad_focused && confirm)
} else {
(mouse_hovered, (mouse_hovered && clicked) || fallback_fire)
}
}
impl UiInputSystem {
pub(super) fn refresh_disabled_rows(&mut self, ctx: &PipelineContext) {
let changed = match ctx.resource::<crate::ecs::DisabledSettingRows>() {
Some(d) => d.0 != self.disabled_rows_cache,
None => !self.disabled_rows_cache.is_empty(),
};
if changed {
self.disabled_rows_cache = ctx
.resource::<crate::ecs::DisabledSettingRows>()
.map(|d| d.0.clone())
.unwrap_or_default();
}
}
fn resolve_follow_labels(&mut self, ctx: &PipelineContext) {
self.follow_labels.clear();
for &id in &self.follow_label_ids {
if let Some(l) = ctx.get_by_id::<TextLabel>(id) {
self.follow_labels.insert(id, (l.y, l.content.is_empty()));
}
}
}
pub(super) fn hit_test_regions(
&mut self,
input: &FrameInput,
intent: &UiIntent,
frame: RegionFrame,
ctx: &mut PipelineContext,
) -> RegionOutcome {
self.resolve_follow_labels(ctx);
let (mx, my) = (input.mouse_x, input.mouse_y);
let [vw, vh] = input.viewport;
let focus_index = self.focus.as_ref().map(|f| f.index);
let confirm_fallback = intent.confirm && focus_index.is_none();
let mut confirm_used = false;
let mut outcome = RegionOutcome::default();
for (i, entry) in self.regions.iter_mut().enumerate() {
let (follow_y, follow_inert) = follow_sync(entry.follow, &self.follow_labels);
if let Some(y) = follow_y {
entry.region.y = y;
}
let pad_focused = focus_index == Some(i);
let gate = RegionGate {
thumb_active: frame.thumb_active,
slider: entry.slider_key.is_some(),
pad_focused,
screen_matches: entry.screen == frame.active_screen,
collapsed_row: entry.scroll_row.is_some() && entry.hidden,
disabled: setting_row_disabled(
&self.disabled_rows_cache,
entry.region.action.as_ref(),
),
follow_inert,
};
if region_inert(gate) {
if entry.was_hovered {
set_label_style(
ctx,
entry.region.label.map(Ref::id),
entry.original_color,
entry.original_scale,
);
entry.was_hovered = false;
}
continue;
}
let full_window = entry.screen.is_some() && region_covers_canvas(&entry.region);
let (qx, qy) = frame.cursor_for(entry.screen, entry.fit, mx, my);
let r = &entry.region;
let mut mouse_hovered = if full_window {
mx >= 0.0 && mx < vw && my >= 0.0 && my < vh
} else {
point_in_rect(qx, qy, region_rect(r))
};
if let Some((pi, _)) = entry.scroll_row
&& let Some(band) = frame.panel_bands.get(pi)
{
mouse_hovered = mouse_hovered && point_in_rect(qx, qy, *band);
}
let fallback_fire = confirm_fallback && full_window && !confirm_used;
let (hovered, fire) = hover_and_fire(
focus_index.is_some(),
pad_focused,
mouse_hovered,
input.left_click,
intent.confirm,
fallback_fire,
);
if hovered && !entry.was_hovered {
set_label_style(ctx, r.label.map(Ref::id), r.hover_color, r.hover_scale);
} else if !hovered && entry.was_hovered {
set_label_style(
ctx,
r.label.map(Ref::id),
entry.original_color,
entry.original_scale,
);
}
entry.was_hovered = hovered;
if !fire {
continue;
}
if fallback_fire {
confirm_used = true;
}
match &r.action {
None => {}
Some(UiAction::GroupToggle(gid)) => outcome.toggle_group = Some(*gid),
Some(UiAction::Setting {
key,
verb: SettingVerb::Rebind,
}) => outcome.start_capture = Some((*key, r.label.map(Ref::id))),
Some(UiAction::Setting {
key,
verb: SettingVerb::Open,
}) => {
outcome.start_open = Some(OpenRequest {
setting: *key,
value_label: r.label.map(Ref::id),
anchor: region_rect(r),
screen: entry.screen,
color: entry.original_color,
scale: entry.original_scale,
});
}
Some(action) => {
if let Some(result) = fire_action(action, r.label.map(Ref::id), ctx) {
outcome.fired = Some(result);
return outcome;
}
}
}
}
outcome
}
}
#[cfg(test)]
mod tests {
use super::*;
fn live() -> RegionGate {
RegionGate {
screen_matches: true,
..Default::default()
}
}
#[test]
fn a_live_region_is_not_inert() {
assert!(!region_inert(live()));
}
#[test]
fn each_gate_makes_a_region_inert() {
let gates = [
RegionGate {
thumb_active: true,
..live()
},
RegionGate {
slider: true,
..live()
},
RegionGate {
screen_matches: false,
..live()
},
RegionGate {
collapsed_row: true,
..live()
},
RegionGate {
disabled: true,
..live()
},
RegionGate {
follow_inert: true,
..live()
},
];
for gate in gates {
assert!(region_inert(gate), "{gate:?}");
}
}
#[test]
fn a_focused_slider_stays_live() {
let gate = RegionGate {
slider: true,
pad_focused: true,
..live()
};
assert!(!region_inert(gate));
}
#[test]
fn follow_sync_tracks_the_label_and_goes_inert_when_empty_or_missing() {
let labels = HashMap::from([(AssetId(1), (40.0, false)), (AssetId(2), (60.0, true))]);
assert_eq!(follow_sync(None, &labels), (None, false));
assert_eq!(
follow_sync(Some((AssetId(1), 5.0)), &labels),
(Some(45.0), false)
);
assert_eq!(
follow_sync(Some((AssetId(2), 5.0)), &labels),
(Some(65.0), true)
);
assert_eq!(follow_sync(Some((AssetId(3), 5.0)), &labels), (None, true));
}
#[test]
fn setting_row_disabled_matches_the_setting_key() {
let rows = HashSet::from([SettingKey::ShowFps]);
let row = |key| UiAction::Setting {
key,
verb: SettingVerb::Next,
};
assert!(setting_row_disabled(&rows, Some(&row(SettingKey::ShowFps))));
assert!(!setting_row_disabled(&rows, Some(&row(SettingKey::Vsync))));
assert!(!setting_row_disabled(&rows, Some(&UiAction::Quit)));
assert!(!setting_row_disabled(&rows, None));
assert!(!setting_row_disabled(
&HashSet::new(),
Some(&row(SettingKey::ShowFps))
));
}
#[test]
fn mouse_hovers_and_clicks_without_focus() {
assert_eq!(
hover_and_fire(false, false, true, false, false, false),
(true, false)
);
assert_eq!(
hover_and_fire(false, false, true, true, false, false),
(true, true)
);
assert_eq!(
hover_and_fire(false, false, false, true, false, false),
(false, false)
);
}
#[test]
fn unfocused_confirm_fires_only_through_the_fallback() {
assert_eq!(
hover_and_fire(false, false, true, false, true, false),
(true, false)
);
assert_eq!(
hover_and_fire(false, false, false, false, true, true),
(false, true)
);
}
#[test]
fn focus_owns_the_hover_slot() {
assert_eq!(
hover_and_fire(true, false, true, true, true, true),
(false, false)
);
assert_eq!(
hover_and_fire(true, true, false, false, false, false),
(true, false)
);
assert_eq!(
hover_and_fire(true, true, false, false, true, false),
(true, true)
);
}
}