use concinnity_core::components::{
HitRegion, Identity, ScrollPanel, SettingVerb, Sprite, TextLabel, UiAction, WindowMode,
};
use concinnity_core::ecs::PipelineContext;
use concinnity_core::ecs::asset_id::AssetId;
use concinnity_core::window::display_mode;
use super::SettingsState;
use crate::gfx::system::{PadRebindViz, RebindViz, SliderViz};
use crate::settings;
use crate::settings::SettingKey;
pub(crate) const DISABLED_ROW_COLOR: [f32; 3] = [0.42, 0.42, 0.47];
pub(crate) fn expand_dim_set(
gated: &std::collections::HashSet<AssetId>,
rows: &[Vec<AssetId>],
) -> std::collections::HashSet<AssetId> {
let mut dim = gated.clone();
for row in rows {
if row.iter().any(|id| gated.contains(id)) {
dim.extend(row.iter().copied());
}
}
dim
}
pub(crate) fn set_rows_grayed(
ctx: &mut PipelineContext,
rows: &[(AssetId, [f32; 3])],
grayed: bool,
) {
for &(id, orig) in rows {
let color = if grayed { DISABLED_ROW_COLOR } else { orig };
crate::ecs::by_asset_id::update::<TextLabel>(ctx, Some(id), |l| l.color = color);
}
}
pub(crate) fn capture_row_labels(
ctx: &mut PipelineContext,
keys: &[SettingKey],
) -> Vec<(AssetId, [f32; 3])> {
let mut anchors: std::collections::HashSet<AssetId> = std::collections::HashSet::new();
for r in ctx.query::<HitRegion>() {
if let Some(UiAction::Setting { key, .. }) = r.action
&& keys.contains(&key)
&& let Some(label) = r.label
{
anchors.insert(label.id());
}
}
if anchors.is_empty() {
return Vec::new();
}
let rows: Vec<Vec<AssetId>> = ctx
.query::<ScrollPanel>()
.flat_map(|p| {
p.rows
.iter()
.map(|r| r.elements.iter().map(|e| e.id()).collect())
})
.collect();
let dim = expand_dim_set(&anchors, &rows);
ctx.join2::<TextLabel, Identity>()
.filter(|(_, _, identity)| dim.contains(&identity.id()))
.map(|(_, l, identity)| (identity.id(), l.color))
.collect()
}
pub(crate) fn set_label_content(ctx: &mut PipelineContext, id: AssetId, text: &str) {
crate::ecs::by_asset_id::set_text(ctx, id, text);
}
pub(crate) fn set_cached_row_label(
labels: &std::collections::HashMap<SettingKey, AssetId>,
ctx: &mut PipelineContext,
key: SettingKey,
text: &str,
) {
if let Some(&id) = labels.get(&key) {
set_label_content(ctx, id, text);
}
}
pub(crate) fn set_sprite_x(ctx: &mut PipelineContext, id: AssetId, x: f32) {
crate::ecs::by_asset_id::update::<Sprite>(ctx, Some(id), |s| s.x = x);
}
impl SettingsState {
pub(crate) fn init_sliders(
&mut self,
ctx: &mut PipelineContext,
persisted: &crate::config::Settings,
) {
let mut sliders: Vec<SliderViz> = Vec::new();
for r in ctx.query::<HitRegion>() {
let Some(UiAction::Setting {
key,
verb: SettingVerb::Drag,
}) = r.action
else {
continue;
};
let (Some(handle_id), Some(value_id)) = (r.drag_handle, r.label) else {
continue;
};
let handle_w = ctx
.get_by_id::<Sprite>(handle_id.id())
.map_or(0.0, |s| s.width);
sliders.push(SliderViz {
key,
track_x: r.x,
track_w: r.width,
handle_w,
handle_id: handle_id.id(),
value_id: value_id.id(),
});
}
for s in &sliders {
let Some(slider) = settings::slider(s.key) else {
continue;
};
let value = slider.current_value(
&self.graphics.post_process,
&self.graphics.quality.post_config,
self.graphics.ambient_intensity,
persisted,
);
let hx = s.track_x + slider.fraction(value) * (s.track_w - s.handle_w).max(0.0);
set_sprite_x(ctx, s.handle_id, hx);
set_label_content(ctx, s.value_id, &(slider.format)(value));
}
self.sliders = sliders;
}
pub(crate) fn init_rebind_rows(&mut self, ctx: &mut PipelineContext) {
let mut rows: Vec<RebindViz> = Vec::new();
let mut pad_rows: Vec<PadRebindViz> = Vec::new();
for r in ctx.query::<HitRegion>() {
let (
Some(UiAction::Setting {
key,
verb: SettingVerb::Rebind,
}),
Some(value_id),
) = (&r.action, r.label)
else {
continue;
};
match *key {
SettingKey::KeyRebind(action) => rows.push(RebindViz {
action,
value_id: value_id.id(),
}),
SettingKey::PadRebind(action) => pad_rows.push(PadRebindViz {
action,
value_id: value_id.id(),
}),
_ => {}
}
}
for row in &rows {
let name = self.keymap.get(row.action).display_name();
set_label_content(ctx, row.value_id, name);
}
for row in &pad_rows {
let name = self.gamepad_map.get(row.action).display_name();
set_label_content(ctx, row.value_id, name);
}
self.rebind_rows = rows;
self.pad_rebind_rows = pad_rows;
}
pub(crate) fn init_cycle_value_labels(&mut self, ctx: &mut PipelineContext) {
let mut labels = std::collections::HashMap::new();
for r in ctx.query::<HitRegion>() {
if let (
Some(UiAction::Setting {
key,
verb: SettingVerb::Next | SettingVerb::Open,
}),
Some(value_id),
) = (&r.action, r.label)
{
labels.insert(*key, value_id.id());
}
}
self.cycle_value_labels = labels;
}
pub(crate) fn capture_perf_sub_rows(&mut self, ctx: &mut PipelineContext) {
self.perf_sub_row_labels =
capture_row_labels(ctx, &[SettingKey::ShowFps, SettingKey::ShowVram]);
set_rows_grayed(ctx, &self.perf_sub_row_labels, !self.graphics.perf_stats);
}
pub(crate) fn capture_resolution_row(&mut self, ctx: &mut PipelineContext) {
self.resolution_row_labels = capture_row_labels(ctx, &[SettingKey::Resolution]);
set_rows_grayed(
ctx,
&self.resolution_row_labels,
self.window_args.mode != WindowMode::Fullscreen,
);
}
pub(crate) fn effective_resolution(&self) -> display_mode::DisplayMode {
self.resolution
.or(self.current_mode)
.unwrap_or(display_mode::DisplayMode {
width: self.window_args.width,
height: self.window_args.height,
refresh_hz: 0,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use concinnity_core::components::ScrollRow;
use concinnity_core::ecs::Ref;
use concinnity_core::ecs::World;
use std::collections::HashSet;
#[test]
fn dim_set_expands_a_gated_value_label_to_its_whole_row() {
let value = AssetId(3);
let gated: HashSet<AssetId> = [value].into_iter().collect();
let rows = vec![
vec![AssetId(1), AssetId(2), value, AssetId(4), AssetId(5)],
vec![AssetId(10), AssetId(11)],
];
let dim = expand_dim_set(&gated, &rows);
for id in [1, 2, 3, 4, 5] {
assert!(dim.contains(&AssetId(id)), "row A element {id} should dim");
}
assert!(!dim.contains(&AssetId(10)), "an unrelated row stays lit");
assert!(!dim.contains(&AssetId(11)), "an unrelated row stays lit");
}
#[test]
fn dim_set_without_rows_falls_back_to_the_value_label() {
let gated: HashSet<AssetId> = [AssetId(7)].into_iter().collect();
assert_eq!(expand_dim_set(&gated, &[]), gated);
}
fn label(color: [f32; 3]) -> TextLabel {
TextLabel {
color,
..Default::default()
}
}
fn region(action: serde_json::Value, label: Option<u32>) -> HitRegion {
HitRegion {
action: Some(serde_json::from_value(action).unwrap()),
label: label.map(AssetId).map(Ref::new),
..Default::default()
}
}
#[test]
fn set_label_content_writes_only_the_matching_label() {
let mut world = World::new();
world.push_identified(AssetId(1), label([1.0; 3]));
world.push_identified(AssetId(2), label([1.0; 3]));
let mut ctx = world.context();
set_label_content(&mut ctx, AssetId(2), "High");
let contents: Vec<&str> = ctx
.query::<TextLabel>()
.map(|l| l.content.as_str())
.collect();
assert_eq!(contents, ["", "High"]);
set_label_content(&mut ctx, AssetId(9), "Ultra");
let contents: Vec<&str> = ctx
.query::<TextLabel>()
.map(|l| l.content.as_str())
.collect();
assert_eq!(contents, ["", "High"], "an absent id changes nothing");
}
#[test]
fn set_sprite_x_moves_only_the_matching_sprite() {
let mut world = World::new();
world.push_identified(
AssetId(1),
Sprite {
x: 0.0,
..Default::default()
},
);
world.push_identified(
AssetId(2),
Sprite {
x: 0.0,
..Default::default()
},
);
let mut ctx = world.context();
set_sprite_x(&mut ctx, AssetId(2), 42.0);
assert_eq!(
ctx.query::<Sprite>().map(|s| s.x).collect::<Vec<_>>(),
[0.0, 42.0]
);
set_sprite_x(&mut ctx, AssetId(9), 99.0);
assert_eq!(
ctx.query::<Sprite>().map(|s| s.x).collect::<Vec<_>>(),
[0.0, 42.0],
"an absent id changes nothing"
);
}
#[test]
fn set_rows_grayed_grays_then_restores_authored_colors() {
let authored = [[0.9, 0.9, 0.9], [0.2, 0.6, 1.0]];
let mut world = World::new();
world.push_identified(AssetId(1), label(authored[0]));
world.push_identified(AssetId(2), label(authored[1]));
let rows = [(AssetId(1), authored[0]), (AssetId(2), authored[1])];
let mut ctx = world.context();
set_rows_grayed(&mut ctx, &rows, true);
assert!(
ctx.query::<TextLabel>()
.all(|l| l.color == DISABLED_ROW_COLOR)
);
set_rows_grayed(&mut ctx, &rows, false);
assert_eq!(
ctx.query::<TextLabel>()
.map(|l| l.color)
.collect::<Vec<_>>(),
authored
);
}
#[test]
fn capture_row_labels_returns_a_matching_rows_labels_and_colors() {
let mut world = World::new();
for id in [1, 2, 3, 4, 5, 20] {
world.push_identified(AssetId(id), label([id as f32 / 100.0; 3]));
}
world.push(region(
serde_json::json!({"setting": {"key": "shadow_map_size", "verb": "next"}}),
Some(3),
));
world.push(region(
serde_json::json!({"setting": {"key": "vsync", "verb": "next"}}),
Some(20),
));
world.push(region(serde_json::json!("quit"), Some(1)));
world.push(region(
serde_json::json!({"setting": {"key": "shadow_map_size", "verb": "prev"}}),
None,
));
world.push(ScrollPanel {
rows: vec![
ScrollRow {
elements: vec![
Ref::new(AssetId(1)),
Ref::new(AssetId(2)),
Ref::new(AssetId(3)),
Ref::new(AssetId(4)),
Ref::new(AssetId(5)),
],
..Default::default()
},
ScrollRow {
elements: vec![Ref::new(AssetId(20))],
..Default::default()
},
],
..Default::default()
});
let mut ctx = world.context();
let captured = capture_row_labels(&mut ctx, &[SettingKey::ShadowMapSize]);
let expected: Vec<(AssetId, [f32; 3])> = [1, 2, 3, 4, 5]
.into_iter()
.map(|id| (AssetId(id), [id as f32 / 100.0; 3]))
.collect();
assert_eq!(captured, expected);
}
#[test]
fn capture_row_labels_without_a_matching_key_captures_nothing() {
let mut world = World::new();
world.push_identified(AssetId(1), label([1.0; 3]));
world.push(region(
serde_json::json!({"setting": {"key": "shadow_map_size", "verb": "next"}}),
Some(1),
));
let mut ctx = world.context();
assert!(capture_row_labels(&mut ctx, &[SettingKey::Resolution]).is_empty());
}
#[test]
fn capture_perf_sub_rows_applies_the_initial_gray() {
let authored = [0.8, 0.8, 0.8];
for (perf_stats, expected) in [(false, DISABLED_ROW_COLOR), (true, authored)] {
let mut world = World::new();
world.push_identified(AssetId(1), label(authored));
world.push(region(
serde_json::json!({"setting": {"key": "show_fps", "verb": "next"}}),
Some(1),
));
let mut state = SettingsState::for_tests();
state.graphics.perf_stats = perf_stats;
let mut ctx = world.context();
state.capture_perf_sub_rows(&mut ctx);
assert_eq!(state.perf_sub_row_labels, [(AssetId(1), authored)]);
assert_eq!(ctx.query::<TextLabel>().next().unwrap().color, expected);
}
}
#[test]
fn capture_resolution_row_grays_outside_fullscreen() {
let authored = [0.8, 0.8, 0.8];
for (mode, expected) in [
(WindowMode::Windowed, DISABLED_ROW_COLOR),
(WindowMode::Borderless, DISABLED_ROW_COLOR),
(WindowMode::Fullscreen, authored),
] {
let mut world = World::new();
world.push_identified(AssetId(1), label(authored));
world.push(region(
serde_json::json!({"setting": {"key": "resolution", "verb": "next"}}),
Some(1),
));
let mut state = SettingsState::for_tests();
state.window_args.mode = mode;
let mut ctx = world.context();
state.capture_resolution_row(&mut ctx);
assert_eq!(state.resolution_row_labels, [(AssetId(1), authored)]);
assert_eq!(
ctx.query::<TextLabel>().next().unwrap().color,
expected,
"{mode:?}"
);
}
}
#[test]
fn init_cycle_value_labels_maps_each_cycle_key_to_its_label() {
let mut world = World::new();
world.push(region(
serde_json::json!({"setting": {"key": "vsync", "verb": "next"}}),
Some(1),
));
world.push(region(
serde_json::json!({"setting": {"key": "vsync", "verb": "prev"}}),
Some(1),
));
world.push(region(
serde_json::json!({"setting": {"key": "exposure", "verb": "drag"}}),
Some(2),
));
let mut state = SettingsState::for_tests();
let mut ctx = world.context();
state.init_cycle_value_labels(&mut ctx);
assert_eq!(state.cycle_value_labels.len(), 1);
assert_eq!(
state.cycle_value_labels.get(&SettingKey::Vsync),
Some(&AssetId(1))
);
}
#[test]
fn effective_resolution_falls_back_from_choice_to_display_to_window() {
let mode = |width, height, refresh_hz| display_mode::DisplayMode {
width,
height,
refresh_hz,
};
let mut state = SettingsState::for_tests();
state.window_args.width = 640;
state.window_args.height = 360;
assert_eq!(state.effective_resolution(), mode(640, 360, 0));
state.current_mode = Some(mode(2560, 1440, 144));
assert_eq!(state.effective_resolution(), mode(2560, 1440, 144));
state.resolution = Some(mode(1920, 1080, 60));
assert_eq!(state.effective_resolution(), mode(1920, 1080, 60));
}
}