use crate::{
layout::Rect,
sanitize::{sanitize_str, sanitize_title, str_display_width},
shader::{
aisling_laser_etch_shader, bloom_glow_shader, code_lens_shader, glass_caustic_shader,
google_aura_shader, pane_shader, prompt_shader, ripple_shader, ShaderDescriptor,
ShaderFamily,
},
OverlayKind, PaneId, Rgba, ScrollState, Vec2,
};
pub const WIDGET_SHADER_SKIN_MAX_LAYERS: usize = 4;
pub const WIDGET_CONTROL_MAX_ITEMS: usize = 128;
pub const WIDGET_PANE_MAX_ITEMS: usize = 32;
pub const WIDGET_TAB_MAX_ITEMS: usize = 48;
pub const WIDGET_OVERLAY_MAX_ITEMS: usize = 16;
pub const WIDGET_SCROLL_AREA_MAX_ITEMS: usize = 64;
pub const WIDGET_CHOICE_MAX_ITEMS: usize = 128;
pub const PROMPT_INPUT_MAX_WIDTH: usize = 4096;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PaneChrome {
pub id: PaneId,
pub title: String,
pub subtitle: String,
pub rect: Rect,
pub focused: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TranscriptItem {
pub role: String,
pub content: String,
pub detail: String,
}
impl TranscriptItem {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: "system".to_string(),
content: content.into(),
detail: String::new(),
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: "user".to_string(),
content: content.into(),
detail: "local prompt".to_string(),
}
}
pub fn assistant(content: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
role: "assistant".to_string(),
content: content.into(),
detail: detail.into(),
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PromptModel {
pub label: String,
pub input: String,
pub cursor: usize,
pub placeholder: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TextSelection {
pub anchor: usize,
pub focus: usize,
}
impl TextSelection {
pub const fn new(anchor: usize, focus: usize) -> Self {
Self { anchor, focus }
}
pub const fn collapsed(cursor: usize) -> Self {
Self {
anchor: cursor,
focus: cursor,
}
}
pub fn is_collapsed(self) -> bool {
self.anchor == self.focus
}
pub fn start(self) -> usize {
self.anchor.min(self.focus)
}
pub fn end(self) -> usize {
self.anchor.max(self.focus)
}
pub fn clamped(self, text_len_chars: usize) -> Self {
Self {
anchor: self.anchor.min(text_len_chars),
focus: self.focus.min(text_len_chars),
}
}
pub fn byte_range(self, text: &str) -> std::ops::Range<usize> {
let selection = self.clamped(text.chars().count());
char_to_byte_index(text, selection.start())..char_to_byte_index(text, selection.end())
}
}
impl PromptModel {
pub fn label(&self) -> &str {
if self.label.is_empty() {
"prompt"
} else {
&self.label
}
}
pub fn is_empty(&self) -> bool {
self.input.is_empty()
}
pub fn showing_placeholder(&self) -> bool {
self.input.is_empty() && !self.placeholder.is_empty()
}
pub fn display_text(&self) -> &str {
if self.input.is_empty() {
&self.placeholder
} else {
&self.input
}
}
pub fn clamped_cursor(&self) -> usize {
self.cursor.min(self.input.chars().count())
}
pub fn cursor_byte_index(&self) -> usize {
char_to_byte_index(&self.input, self.clamped_cursor())
}
pub fn input_char_count(&self) -> usize {
self.input.chars().count()
}
pub fn sanitize_input(&mut self) {
let cursor_byte = self.cursor_byte_index();
let safe_prefix_chars = sanitize_prompt_input(&self.input[..cursor_byte])
.chars()
.count();
self.input = sanitize_prompt_input(&self.input);
self.cursor = safe_prefix_chars.min(self.input_char_count());
}
pub fn set_cursor(&mut self, cursor: usize) {
self.cursor = cursor.min(self.input_char_count());
}
pub fn move_cursor(&mut self, delta: isize) {
let current = self.clamped_cursor();
let next = if delta.is_negative() {
current.saturating_sub(delta.unsigned_abs())
} else {
current.saturating_add(delta as usize)
};
self.set_cursor(next);
}
pub fn move_cursor_to_start(&mut self) {
self.cursor = 0;
}
pub fn move_cursor_to_end(&mut self) {
self.cursor = self.input_char_count();
}
pub fn move_cursor_to_previous_word(&mut self) {
self.cursor = previous_word_cursor(&self.input, self.clamped_cursor());
}
pub fn move_cursor_to_next_word(&mut self) {
self.cursor = next_word_cursor(&self.input, self.clamped_cursor());
}
pub fn selected_text(&self, selection: TextSelection) -> &str {
let range = selection.byte_range(&self.input);
&self.input[range]
}
pub fn delete_selection(&mut self, selection: TextSelection) -> bool {
self.replace_selection(selection, "")
}
pub fn replace_selection(&mut self, selection: TextSelection, text: impl AsRef<str>) -> bool {
self.sanitize_input();
let selection = selection.clamped(self.input_char_count());
let start = selection.start();
let removed = !selection.is_collapsed();
if removed {
let range = selection.byte_range(&self.input);
self.input.replace_range(range, "");
}
self.cursor = start.min(self.input_char_count());
self.insert_text(text) || removed
}
pub fn insert_text(&mut self, text: impl AsRef<str>) -> bool {
self.sanitize_input();
let remaining_width = PROMPT_INPUT_MAX_WIDTH.saturating_sub(str_display_width(&self.input));
if remaining_width == 0 {
return false;
}
let inserted = sanitize_str(text.as_ref(), remaining_width);
if inserted.is_empty() {
return false;
}
let byte_index = self.cursor_byte_index();
self.input.insert_str(byte_index, &inserted);
self.cursor = self
.cursor
.saturating_add(inserted.chars().count())
.min(self.input_char_count());
true
}
pub fn backspace(&mut self) -> bool {
self.sanitize_input();
let cursor = self.clamped_cursor();
if cursor == 0 {
return false;
}
let selection = TextSelection::new(cursor - 1, cursor);
let range = selection.byte_range(&self.input);
self.input.replace_range(range, "");
self.cursor = cursor - 1;
true
}
pub fn delete_forward(&mut self) -> bool {
self.sanitize_input();
let cursor = self.clamped_cursor();
if cursor >= self.input_char_count() {
return false;
}
let selection = TextSelection::new(cursor, cursor + 1);
let range = selection.byte_range(&self.input);
self.input.replace_range(range, "");
self.cursor = cursor;
true
}
pub fn clear_input(&mut self) -> bool {
if self.input.is_empty() && self.cursor == 0 {
return false;
}
self.input.clear();
self.cursor = 0;
true
}
}
fn sanitize_prompt_input(input: &str) -> String {
sanitize_str(input, PROMPT_INPUT_MAX_WIDTH)
}
fn char_to_byte_index(text: &str, char_index: usize) -> usize {
text.char_indices()
.nth(char_index.min(text.chars().count()))
.map(|(index, _)| index)
.unwrap_or(text.len())
}
fn previous_word_cursor(text: &str, cursor: usize) -> usize {
let chars = text.chars().collect::<Vec<_>>();
let mut index = cursor.min(chars.len());
while index > 0 && chars[index - 1].is_whitespace() {
index -= 1;
}
while index > 0 && !chars[index - 1].is_whitespace() {
index -= 1;
}
index
}
fn next_word_cursor(text: &str, cursor: usize) -> usize {
let chars = text.chars().collect::<Vec<_>>();
let mut index = cursor.min(chars.len());
while index < chars.len() && !chars[index].is_whitespace() {
index += 1;
}
while index < chars.len() && chars[index].is_whitespace() {
index += 1;
}
index
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetSize {
Compact,
Regular,
Spacious,
}
impl WidgetSize {
pub fn metrics(self) -> WidgetMetrics {
match self {
Self::Compact => WidgetMetrics {
min_height_px: 28.0,
padding_x_px: 10.0,
padding_y_px: 5.0,
gap_px: 6.0,
radius_px: 10.0,
border_width_px: 1.0,
},
Self::Regular => WidgetMetrics {
min_height_px: 36.0,
padding_x_px: 14.0,
padding_y_px: 8.0,
gap_px: 8.0,
radius_px: 14.0,
border_width_px: 1.0,
},
Self::Spacious => WidgetMetrics {
min_height_px: 48.0,
padding_x_px: 18.0,
padding_y_px: 12.0,
gap_px: 12.0,
radius_px: 20.0,
border_width_px: 1.25,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetMetrics {
pub min_height_px: f32,
pub padding_x_px: f32,
pub padding_y_px: f32,
pub gap_px: f32,
pub radius_px: f32,
pub border_width_px: f32,
}
impl WidgetMetrics {
pub fn sanitized(self) -> Self {
Self {
min_height_px: sanitize_px(self.min_height_px, 16.0, 240.0, 36.0),
padding_x_px: sanitize_px(self.padding_x_px, 0.0, 96.0, 14.0),
padding_y_px: sanitize_px(self.padding_y_px, 0.0, 96.0, 8.0),
gap_px: sanitize_px(self.gap_px, 0.0, 96.0, 8.0),
radius_px: sanitize_px(self.radius_px, 0.0, 128.0, 14.0),
border_width_px: sanitize_px(self.border_width_px, 0.0, 16.0, 1.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetVariant {
Filled,
Tonal,
Outline,
Ghost,
Glass,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetInteractionStatus {
#[default]
Idle,
Hovered,
Pressed,
Focused,
Selected,
Active,
Disabled,
}
impl WidgetInteractionStatus {
pub const fn is_interactive(self) -> bool {
!matches!(self, Self::Disabled)
}
pub fn state(self) -> WidgetState {
match self {
Self::Idle => WidgetState::new(),
Self::Hovered => WidgetState::new().hovered(true),
Self::Pressed => WidgetState::new().hovered(true).pressed(true),
Self::Focused => WidgetState::new().focused(true),
Self::Selected => WidgetState::new().selected(true),
Self::Active => WidgetState::new().focused(true).selected(true),
Self::Disabled => WidgetState::new().disabled(true),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetState {
pub hovered: bool,
pub pressed: bool,
pub focused: bool,
pub disabled: bool,
pub selected: bool,
}
impl WidgetState {
pub const fn new() -> Self {
Self {
hovered: false,
pressed: false,
focused: false,
disabled: false,
selected: false,
}
}
pub fn hovered(mut self, hovered: bool) -> Self {
self.hovered = hovered;
self
}
pub fn pressed(mut self, pressed: bool) -> Self {
self.pressed = pressed;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn merge(self, other: Self) -> Self {
Self {
hovered: self.hovered || other.hovered,
pressed: self.pressed || other.pressed,
focused: self.focused || other.focused,
disabled: self.disabled || other.disabled,
selected: self.selected || other.selected,
}
}
pub fn from_status(status: WidgetInteractionStatus) -> Self {
status.state()
}
pub fn with_status(self, status: WidgetInteractionStatus) -> Self {
self.merge(status.state())
}
pub fn status(self) -> WidgetInteractionStatus {
if self.disabled {
WidgetInteractionStatus::Disabled
} else if self.pressed {
WidgetInteractionStatus::Pressed
} else if self.selected && self.focused {
WidgetInteractionStatus::Active
} else if self.selected {
WidgetInteractionStatus::Selected
} else if self.focused {
WidgetInteractionStatus::Focused
} else if self.hovered {
WidgetInteractionStatus::Hovered
} else {
WidgetInteractionStatus::Idle
}
}
pub fn visual_weight(self) -> f32 {
if self.disabled {
return 0.0;
}
let mut weight: f32 = 0.42;
if self.hovered {
weight += 0.16;
}
if self.focused {
weight += 0.18;
}
if self.selected {
weight += 0.22;
}
if self.pressed {
weight += 0.26;
}
weight.clamp(0.0, 1.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetStyleDescriptor {
pub variant: WidgetVariant,
pub size: WidgetSize,
pub state: WidgetState,
pub accent: Rgba,
pub overrides: WidgetStyleOverride,
}
impl WidgetStyleDescriptor {
pub fn new(variant: WidgetVariant, size: WidgetSize) -> Self {
Self {
variant,
size,
state: WidgetState::new(),
accent: Rgba::rgb8(122, 185, 255),
overrides: WidgetStyleOverride::default(),
}
}
pub fn with_state(mut self, state: WidgetState) -> Self {
self.state = state;
self
}
pub fn with_accent(mut self, accent: Rgba) -> Self {
self.accent = accent.sanitized();
self
}
pub fn with_overrides(mut self, overrides: WidgetStyleOverride) -> Self {
self.overrides = overrides.sanitized();
self
}
pub fn resolve(self) -> WidgetResolvedStyle {
let accent = self.accent.sanitized();
let metrics = self.size.metrics().sanitized();
let state = self.state;
let weight = state.visual_weight();
let panel = Rgba::rgba8(10, 16, 34, 226);
let hot_panel = Rgba::rgba8(34, 48, 86, 240);
let clear_panel = Rgba::rgba8(10, 16, 34, 0);
let fill = match self.variant {
WidgetVariant::Filled => accent.mix(Rgba::WHITE, weight * 0.12).with_alpha(0.9),
WidgetVariant::Tonal => panel.mix(accent, 0.26 + weight * 0.22).with_alpha(0.82),
WidgetVariant::Outline => panel.with_alpha(0.22 + weight * 0.18),
WidgetVariant::Ghost => clear_panel.with_alpha(if weight > 0.42 {
0.14 + weight * 0.1
} else {
0.0
}),
WidgetVariant::Glass => hot_panel
.mix(accent, 0.12 + weight * 0.16)
.with_alpha(0.62 + weight * 0.16),
};
let stroke = match self.variant {
WidgetVariant::Outline => accent.with_alpha(0.52 + weight * 0.34),
WidgetVariant::Ghost => {
accent.with_alpha(if state.focused { 0.52 } else { weight * 0.22 })
}
WidgetVariant::Glass => Rgba::WHITE
.mix(accent, 0.46)
.with_alpha(0.42 + weight * 0.22),
_ => accent
.mix(Rgba::WHITE, 0.26)
.with_alpha(0.24 + weight * 0.32),
};
let foreground = if matches!(self.variant, WidgetVariant::Filled) {
fill.readable_text_color()
} else {
Rgba::rgb8(226, 236, 255).mix(accent, if state.selected { 0.18 } else { 0.0 })
}
.with_alpha(if state.disabled { 0.46 } else { 1.0 });
let glow = accent.with_alpha(if state.disabled {
0.0
} else if state.focused || state.selected {
0.44 + weight * 0.12
} else if state.hovered {
0.24
} else {
0.12
});
let opacity = if state.disabled { 0.44 } else { 1.0 };
let base_elevation = match self.variant {
WidgetVariant::Filled => 3.0,
WidgetVariant::Tonal => 2.0,
WidgetVariant::Outline | WidgetVariant::Ghost => 0.0,
WidgetVariant::Glass => 7.0,
};
let elevation_px = sanitize_px(base_elevation + weight * 5.0, 0.0, 64.0, 0.0);
let shader_intensity = sanitize_intensity(if state.disabled {
0.18
} else {
0.36 + weight * 0.64
});
self.overrides.apply_to(WidgetResolvedStyle {
variant: self.variant,
size: self.size,
state,
accent,
metrics,
fill,
stroke,
foreground,
glow,
opacity,
elevation_px,
shader_intensity,
})
}
}
impl Default for WidgetStyleDescriptor {
fn default() -> Self {
Self::new(WidgetVariant::Filled, WidgetSize::Regular)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetResolvedStyle {
pub variant: WidgetVariant,
pub size: WidgetSize,
pub state: WidgetState,
pub accent: Rgba,
pub metrics: WidgetMetrics,
pub fill: Rgba,
pub stroke: Rgba,
pub foreground: Rgba,
pub glow: Rgba,
pub opacity: f32,
pub elevation_px: f32,
pub shader_intensity: f32,
}
impl WidgetResolvedStyle {
pub fn resolve(
variant: WidgetVariant,
size: WidgetSize,
state: WidgetState,
accent: Rgba,
) -> Self {
WidgetStyleDescriptor::new(variant, size)
.with_state(state)
.with_accent(accent)
.resolve()
}
pub fn with_overrides(self, overrides: WidgetStyleOverride) -> Self {
overrides.apply_to(self)
}
pub fn sanitized(self) -> Self {
Self {
variant: self.variant,
size: self.size,
state: self.state,
accent: self.accent.sanitized(),
metrics: self.metrics.sanitized(),
fill: self.fill.sanitized(),
stroke: self.stroke.sanitized(),
foreground: self.foreground.sanitized(),
glow: self.glow.sanitized(),
opacity: sanitize_unit(self.opacity, 1.0),
elevation_px: sanitize_px(self.elevation_px, 0.0, 64.0, 0.0),
shader_intensity: sanitize_intensity(self.shader_intensity),
}
}
pub fn material(self, descriptor: WidgetMaterialDescriptor) -> WidgetResolvedMaterial {
descriptor.resolve(self)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetStyleOverride {
pub fill: Option<Rgba>,
pub stroke: Option<Rgba>,
pub foreground: Option<Rgba>,
pub glow: Option<Rgba>,
pub opacity: Option<f32>,
pub elevation_px: Option<f32>,
pub radius_px: Option<f32>,
pub border_width_px: Option<f32>,
pub shader_intensity: Option<f32>,
}
impl WidgetStyleOverride {
pub const fn empty() -> Self {
Self {
fill: None,
stroke: None,
foreground: None,
glow: None,
opacity: None,
elevation_px: None,
radius_px: None,
border_width_px: None,
shader_intensity: None,
}
}
pub fn with_fill(mut self, fill: Rgba) -> Self {
self.fill = Some(fill.sanitized());
self
}
pub fn with_stroke(mut self, stroke: Rgba) -> Self {
self.stroke = Some(stroke.sanitized());
self
}
pub fn with_foreground(mut self, foreground: Rgba) -> Self {
self.foreground = Some(foreground.sanitized());
self
}
pub fn with_glow(mut self, glow: Rgba) -> Self {
self.glow = Some(glow.sanitized());
self
}
pub fn with_opacity(mut self, opacity: f32) -> Self {
self.opacity = Some(sanitize_unit(opacity, 1.0));
self
}
pub fn with_elevation_px(mut self, elevation_px: f32) -> Self {
self.elevation_px = Some(sanitize_px(elevation_px, 0.0, 64.0, 0.0));
self
}
pub fn with_radius_px(mut self, radius_px: f32) -> Self {
self.radius_px = Some(sanitize_px(radius_px, 0.0, 128.0, 14.0));
self
}
pub fn with_border_width_px(mut self, border_width_px: f32) -> Self {
self.border_width_px = Some(sanitize_px(border_width_px, 0.0, 16.0, 1.0));
self
}
pub fn with_shader_intensity(mut self, shader_intensity: f32) -> Self {
self.shader_intensity = Some(sanitize_intensity(shader_intensity));
self
}
pub fn sanitized(self) -> Self {
Self {
fill: self.fill.map(Rgba::sanitized),
stroke: self.stroke.map(Rgba::sanitized),
foreground: self.foreground.map(Rgba::sanitized),
glow: self.glow.map(Rgba::sanitized),
opacity: self.opacity.map(|value| sanitize_unit(value, 1.0)),
elevation_px: self
.elevation_px
.map(|value| sanitize_px(value, 0.0, 64.0, 0.0)),
radius_px: self
.radius_px
.map(|value| sanitize_px(value, 0.0, 128.0, 14.0)),
border_width_px: self
.border_width_px
.map(|value| sanitize_px(value, 0.0, 16.0, 1.0)),
shader_intensity: self.shader_intensity.map(sanitize_intensity),
}
}
pub fn apply_to(self, style: WidgetResolvedStyle) -> WidgetResolvedStyle {
let mut style = style.sanitized();
let overrides = self.sanitized();
if let Some(fill) = overrides.fill {
style.fill = fill;
}
if let Some(stroke) = overrides.stroke {
style.stroke = stroke;
}
if let Some(foreground) = overrides.foreground {
style.foreground = foreground;
}
if let Some(glow) = overrides.glow {
style.glow = glow;
}
if let Some(opacity) = overrides.opacity {
style.opacity = opacity;
}
if let Some(elevation_px) = overrides.elevation_px {
style.elevation_px = elevation_px;
}
if let Some(radius_px) = overrides.radius_px {
style.metrics.radius_px = radius_px;
}
if let Some(border_width_px) = overrides.border_width_px {
style.metrics.border_width_px = border_width_px;
}
style.metrics = style.metrics.sanitized();
if let Some(shader_intensity) = overrides.shader_intensity {
style.shader_intensity = shader_intensity;
}
style
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetMaterialKind {
Flat,
Elevated,
Glass,
LiquidGlass,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetMaterialQuality {
Fallback,
Balanced,
High,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetMaterialShadow {
pub color: Rgba,
pub offset: Vec2,
pub blur_px: f32,
pub spread_px: f32,
}
impl WidgetMaterialShadow {
pub const fn none() -> Self {
Self {
color: Rgba::new(0.0, 0.0, 0.0, 0.0),
offset: Vec2::ZERO,
blur_px: 0.0,
spread_px: 0.0,
}
}
pub fn soft(elevation_px: f32) -> Self {
let elevation_px = sanitize_px(elevation_px, 0.0, 64.0, 0.0);
Self {
color: Rgba::rgba8(0, 0, 0, 112).with_alpha((0.16 + elevation_px * 0.014).min(0.42)),
offset: Vec2::new(0.0, (elevation_px * 0.42).min(24.0)),
blur_px: 10.0 + elevation_px * 3.2,
spread_px: -(elevation_px * 0.08).min(3.0),
}
}
pub fn sanitized(self) -> Self {
Self {
color: self.color.sanitized(),
offset: Vec2::new(
sanitize_px(self.offset.x, -256.0, 256.0, 0.0),
sanitize_px(self.offset.y, -256.0, 256.0, 0.0),
),
blur_px: sanitize_px(self.blur_px, 0.0, 256.0, 0.0),
spread_px: sanitize_px(self.spread_px, -64.0, 64.0, 0.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetMaterialDescriptor {
pub kind: WidgetMaterialKind,
pub quality: WidgetMaterialQuality,
pub blur_px: f32,
pub saturation: f32,
pub refraction: f32,
pub thickness_px: f32,
pub rim_intensity: f32,
pub specular_intensity: f32,
pub light_angle_rad: f32,
pub shadow: WidgetMaterialShadow,
}
impl WidgetMaterialDescriptor {
pub fn flat() -> Self {
Self {
kind: WidgetMaterialKind::Flat,
quality: WidgetMaterialQuality::Fallback,
blur_px: 0.0,
saturation: 1.0,
refraction: 0.0,
thickness_px: 0.0,
rim_intensity: 0.0,
specular_intensity: 0.0,
light_angle_rad: -0.75,
shadow: WidgetMaterialShadow::none(),
}
}
pub fn elevated(elevation_px: f32) -> Self {
let elevation_px = sanitize_px(elevation_px, 0.0, 64.0, 3.0);
Self {
kind: WidgetMaterialKind::Elevated,
quality: WidgetMaterialQuality::Balanced,
blur_px: 0.0,
saturation: 1.0,
refraction: 0.0,
thickness_px: 0.0,
rim_intensity: 0.18,
specular_intensity: 0.12,
light_angle_rad: -0.75,
shadow: WidgetMaterialShadow::soft(elevation_px),
}
}
pub fn glass() -> Self {
Self {
kind: WidgetMaterialKind::Glass,
quality: WidgetMaterialQuality::Balanced,
blur_px: 22.0,
saturation: 1.18,
refraction: 0.12,
thickness_px: 1.2,
rim_intensity: 0.38,
specular_intensity: 0.32,
light_angle_rad: -0.75,
shadow: WidgetMaterialShadow::soft(6.0),
}
}
pub fn liquid_glass() -> Self {
Self {
kind: WidgetMaterialKind::LiquidGlass,
quality: WidgetMaterialQuality::High,
blur_px: 34.0,
saturation: 1.36,
refraction: 0.24,
thickness_px: 2.0,
rim_intensity: 0.58,
specular_intensity: 0.66,
light_angle_rad: -0.75,
shadow: WidgetMaterialShadow::soft(9.0),
}
}
pub fn with_quality(mut self, quality: WidgetMaterialQuality) -> Self {
self.quality = quality;
self
}
pub fn with_light_angle_rad(mut self, light_angle_rad: f32) -> Self {
self.light_angle_rad = sanitize_angle(light_angle_rad);
self
}
pub fn with_shadow(mut self, shadow: WidgetMaterialShadow) -> Self {
self.shadow = shadow.sanitized();
self
}
pub fn sanitized(self) -> Self {
Self {
kind: self.kind,
quality: self.quality,
blur_px: sanitize_px(self.blur_px, 0.0, 96.0, 0.0),
saturation: sanitize_px(self.saturation, 0.0, 3.0, 1.0),
refraction: sanitize_unit(self.refraction, 0.0),
thickness_px: sanitize_px(self.thickness_px, 0.0, 24.0, 0.0),
rim_intensity: sanitize_intensity(self.rim_intensity),
specular_intensity: sanitize_intensity(self.specular_intensity),
light_angle_rad: sanitize_angle(self.light_angle_rad),
shadow: self.shadow.sanitized(),
}
}
pub fn resolve(self, style: WidgetResolvedStyle) -> WidgetResolvedMaterial {
let descriptor = self.sanitized();
let style = style.sanitized();
let quality = descriptor.quality;
let quality_factor = match quality {
WidgetMaterialQuality::Fallback => 0.0,
WidgetMaterialQuality::Balanced => 0.68,
WidgetMaterialQuality::High => 1.0,
};
let material_factor = match descriptor.kind {
WidgetMaterialKind::Flat => 0.0,
WidgetMaterialKind::Elevated => 0.18,
WidgetMaterialKind::Glass => 0.72,
WidgetMaterialKind::LiquidGlass => 1.0,
};
let blur_px = descriptor.blur_px * quality_factor;
let refraction = if quality == WidgetMaterialQuality::Fallback {
0.0
} else {
descriptor.refraction * quality_factor
};
let saturation = 1.0 + (descriptor.saturation - 1.0) * quality_factor;
let thickness_px = descriptor.thickness_px * quality_factor;
let rim_intensity = descriptor.rim_intensity * quality_factor;
let specular_intensity = descriptor.specular_intensity * quality_factor;
let fill_alpha = match descriptor.kind {
WidgetMaterialKind::Flat => style.fill.a,
WidgetMaterialKind::Elevated => style.fill.a.max(0.74),
WidgetMaterialKind::Glass => (style.fill.a * 0.74).clamp(0.38, 0.82),
WidgetMaterialKind::LiquidGlass => (style.fill.a * 0.66).clamp(0.32, 0.76),
} * style.opacity;
let fill = style
.fill
.mix(style.accent, material_factor * 0.18)
.with_alpha(fill_alpha);
let stroke = style
.stroke
.mix(Rgba::WHITE, material_factor * 0.24)
.with_alpha((style.stroke.a + rim_intensity * 0.18).clamp(0.0, 1.0));
let glow = style
.glow
.mix(style.accent, material_factor * 0.22)
.with_alpha((style.glow.a + specular_intensity * 0.08).clamp(0.0, 1.0));
let shadow = if descriptor.kind == WidgetMaterialKind::Flat
|| quality == WidgetMaterialQuality::Fallback
{
WidgetMaterialShadow::none()
} else {
descriptor.shadow.sanitized()
};
let fallback_fill = style
.fill
.mix(style.accent, material_factor * 0.08)
.with_alpha(style.opacity);
let shader_intensity =
sanitize_intensity(style.shader_intensity * (0.44 + material_factor * quality_factor));
WidgetResolvedMaterial {
kind: descriptor.kind,
quality,
fill,
stroke,
glow,
shadow,
fallback_fill,
blur_px,
saturation,
refraction,
thickness_px,
rim_intensity,
specular_intensity,
light_angle_rad: descriptor.light_angle_rad,
opacity: style.opacity,
shader_intensity,
}
.sanitized()
}
}
impl Default for WidgetMaterialDescriptor {
fn default() -> Self {
Self::flat()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetResolvedMaterial {
pub kind: WidgetMaterialKind,
pub quality: WidgetMaterialQuality,
pub fill: Rgba,
pub stroke: Rgba,
pub glow: Rgba,
pub shadow: WidgetMaterialShadow,
pub fallback_fill: Rgba,
pub blur_px: f32,
pub saturation: f32,
pub refraction: f32,
pub thickness_px: f32,
pub rim_intensity: f32,
pub specular_intensity: f32,
pub light_angle_rad: f32,
pub opacity: f32,
pub shader_intensity: f32,
}
impl WidgetResolvedMaterial {
pub fn sanitized(self) -> Self {
Self {
kind: self.kind,
quality: self.quality,
fill: self.fill.sanitized(),
stroke: self.stroke.sanitized(),
glow: self.glow.sanitized(),
shadow: self.shadow.sanitized(),
fallback_fill: self.fallback_fill.sanitized(),
blur_px: sanitize_px(self.blur_px, 0.0, 96.0, 0.0),
saturation: sanitize_px(self.saturation, 0.0, 3.0, 1.0),
refraction: sanitize_unit(self.refraction, 0.0),
thickness_px: sanitize_px(self.thickness_px, 0.0, 24.0, 0.0),
rim_intensity: sanitize_intensity(self.rim_intensity),
specular_intensity: sanitize_intensity(self.specular_intensity),
light_angle_rad: sanitize_angle(self.light_angle_rad),
opacity: sanitize_unit(self.opacity, 1.0),
shader_intensity: sanitize_intensity(self.shader_intensity),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetControlKind {
Button,
IconButton,
Toggle,
Checkbox,
TextInput,
Slider,
Select,
ListItem,
Label,
Divider,
Card,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetChoiceItem {
pub id: String,
pub label: String,
pub disabled: bool,
}
impl WidgetChoiceItem {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: sanitize_widget_id(&id.into()),
label: sanitize_widget_label(&label.into(), 96),
disabled: false,
}
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetChoiceList {
pub items: Vec<WidgetChoiceItem>,
pub selected_index: Option<usize>,
}
impl WidgetChoiceList {
pub fn new(items: impl IntoIterator<Item = WidgetChoiceItem>) -> Self {
let mut list = Self {
items: items
.into_iter()
.take(WIDGET_CHOICE_MAX_ITEMS)
.map(sanitize_choice_item)
.collect(),
selected_index: None,
};
list.ensure_valid_selection();
list
}
pub fn with_selected_id(mut self, id: &str) -> Self {
self.set_selected_id(id);
self
}
pub fn selected(&self) -> Option<&WidgetChoiceItem> {
self.valid_selected_index()
.or_else(|| first_enabled_index(&self.items))
.and_then(|index| self.items.get(index))
}
pub fn selected_id(&self) -> Option<&str> {
self.selected().map(|item| item.id.as_str())
}
pub fn set_selected_index(&mut self, index: usize) -> bool {
if self.items.get(index).is_some_and(|item| !item.disabled) {
self.selected_index = Some(index);
true
} else {
false
}
}
pub fn set_selected_id(&mut self, id: &str) -> bool {
let id = sanitize_widget_id(id);
if let Some(index) = self
.items
.iter()
.position(|item| item.id == id && !item.disabled)
{
self.selected_index = Some(index);
true
} else {
false
}
}
pub fn move_selection(&mut self, delta: isize) -> Option<&WidgetChoiceItem> {
let enabled = self.enabled_indices();
if enabled.is_empty() {
self.selected_index = None;
return None;
}
let current_index = self.valid_selected_index().unwrap_or(enabled[0]);
let current = enabled
.iter()
.position(|index| *index == current_index)
.unwrap_or(0) as i128;
let len = enabled.len() as i128;
let next = (current + delta as i128).rem_euclid(len) as usize;
self.selected_index = Some(enabled[next]);
self.selected()
}
pub fn select_next(&mut self) -> Option<&WidgetChoiceItem> {
self.move_selection(1)
}
pub fn select_previous(&mut self) -> Option<&WidgetChoiceItem> {
self.move_selection(-1)
}
pub fn ensure_valid_selection(&mut self) -> Option<&WidgetChoiceItem> {
self.selected_index = self
.valid_selected_index()
.or_else(|| first_enabled_index(&self.items));
self.selected()
}
fn valid_selected_index(&self) -> Option<usize> {
self.selected_index
.and_then(|index| self.items.get(index).map(|item| (index, item)))
.filter(|(_, item)| !item.disabled)
.map(|(index, _)| index)
}
fn enabled_indices(&self) -> Vec<usize> {
self.items
.iter()
.enumerate()
.filter_map(|(index, item)| (!item.disabled).then_some(index))
.collect()
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetControl {
pub id: String,
pub kind: WidgetControlKind,
pub label: String,
pub value: String,
pub rect: Rect,
pub disabled: bool,
pub selected: bool,
pub accent: Rgba,
}
impl WidgetControl {
pub fn new(id: impl Into<String>, kind: WidgetControlKind, label: impl Into<String>) -> Self {
Self {
id: sanitize_widget_id(&id.into()),
kind,
label: sanitize_widget_label(&label.into(), 96),
value: String::new(),
rect: Rect::ZERO,
disabled: false,
selected: false,
accent: Rgba::WHITE,
}
}
pub fn with_value(mut self, value: impl Into<String>) -> Self {
self.value = sanitize_widget_label(&value.into(), 160);
self
}
pub fn with_rect(mut self, rect: Rect) -> Self {
self.rect = sanitize_rect(rect);
self
}
pub fn with_accent(mut self, accent: Rgba) -> Self {
self.accent = accent.sanitized();
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn state(&self) -> WidgetState {
WidgetState::new()
.disabled(self.disabled)
.selected(self.selected)
}
pub fn resolved_style(&self, descriptor: WidgetStyleDescriptor) -> WidgetResolvedStyle {
descriptor
.with_accent(self.accent)
.with_state(descriptor.state.merge(self.state()))
.resolve()
}
pub fn contains(&self, point: Vec2) -> bool {
!self.disabled && self.rect.contains(point.x, point.y)
}
pub fn interaction_status(
&self,
point: Vec2,
pointer_pressed: bool,
focused: bool,
) -> WidgetInteractionStatus {
if self.disabled {
return WidgetInteractionStatus::Disabled;
}
let hovered = self.rect.contains(point.x, point.y);
if hovered && pointer_pressed && self.is_interactive() {
WidgetInteractionStatus::Pressed
} else if self.selected && focused {
WidgetInteractionStatus::Active
} else if self.selected {
WidgetInteractionStatus::Selected
} else if focused {
WidgetInteractionStatus::Focused
} else if hovered {
WidgetInteractionStatus::Hovered
} else {
WidgetInteractionStatus::Idle
}
}
pub fn state_at(&self, point: Vec2, pointer_pressed: bool, focused: bool) -> WidgetState {
self.state()
.with_status(self.interaction_status(point, pointer_pressed, focused))
}
pub fn is_interactive(&self) -> bool {
!self.disabled
&& !matches!(
self.kind,
WidgetControlKind::Label | WidgetControlKind::Divider
)
}
pub fn is_checked(&self) -> bool {
matches!(
self.kind,
WidgetControlKind::Toggle | WidgetControlKind::Checkbox
) && self.selected
}
pub fn slider_value(&self) -> Option<f32> {
matches!(self.kind, WidgetControlKind::Slider).then(|| parse_slider_value(&self.value))
}
pub fn set_slider_value(&mut self, value: f32) -> bool {
if self.disabled || !matches!(self.kind, WidgetControlKind::Slider) {
return false;
}
self.value = format_slider_value(value);
true
}
pub fn adjust_slider_value(&mut self, delta: f32) -> bool {
let Some(current) = self.slider_value() else {
return false;
};
self.set_slider_value(current + sanitize_delta(delta))
}
pub fn set_value(&mut self, value: impl Into<String>) -> bool {
if self.disabled {
return false;
}
self.value = sanitize_widget_label(&value.into(), 160);
true
}
pub fn activate(&mut self) -> bool {
if !self.is_interactive() {
return false;
}
if matches!(
self.kind,
WidgetControlKind::Toggle | WidgetControlKind::Checkbox
) {
self.selected = !self.selected;
} else if matches!(self.kind, WidgetControlKind::ListItem) {
self.selected = true;
}
true
}
pub fn shader_skin(&self) -> WidgetShaderSkin {
let surface = match self.kind {
WidgetControlKind::TextInput => WidgetSurfaceKind::Prompt,
WidgetControlKind::Card => WidgetSurfaceKind::Pane,
_ => WidgetSurfaceKind::Control,
};
let activity = if self.disabled {
0.18
} else if self.selected {
0.9
} else {
0.56
};
WidgetShaderSkin::empty(surface)
.with_layer(WidgetShaderLayer::new(
WidgetShaderRole::Surface,
pane_descriptor(),
))
.with_layer(
WidgetShaderLayer::new(WidgetShaderRole::Interaction, bloom_descriptor())
.with_intensity(activity),
)
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ScrollAreaModel {
pub id: String,
pub rect: Rect,
pub content_items: usize,
pub viewport_items: usize,
pub scroll: ScrollState,
}
impl ScrollAreaModel {
pub fn new(
id: impl Into<String>,
rect: Rect,
content_items: usize,
viewport_items: usize,
) -> Self {
let mut scroll = ScrollState::new();
scroll.set_bounds(content_items, viewport_items.max(1));
Self {
id: sanitize_widget_id(&id.into()),
rect: sanitize_rect(rect),
content_items,
viewport_items: viewport_items.max(1),
scroll,
}
}
pub fn set_bounds(&mut self, content_items: usize, viewport_items: usize) {
self.content_items = content_items;
self.viewport_items = viewport_items.max(1);
self.scroll
.set_bounds(self.content_items, self.viewport_items);
}
pub fn visible_range(&self) -> std::ops::Range<usize> {
let mut scroll = self.scroll;
scroll.set_bounds(self.content_items, self.viewport_items);
scroll.visible_range()
}
pub fn scroll_by(&mut self, delta: isize) {
self.scroll
.set_bounds(self.content_items, self.viewport_items);
self.scroll.scroll_by(delta);
}
pub fn scrollbar(&self) -> ScrollbarModel {
let rect = sanitize_rect(self.rect);
let track_width = 6.0_f32.min(rect.width.max(0.0));
let track = Rect::new(rect.right() - track_width, rect.y, track_width, rect.height);
if rect.is_empty() || self.content_items <= self.viewport_items.max(1) {
return ScrollbarModel {
track,
thumb: Rect::ZERO,
visible: false,
};
}
let total = self.content_items.max(1) as f32;
let viewport = self.viewport_items.max(1).min(self.content_items) as f32;
let min_thumb_height = rect.height.clamp(0.0, 18.0);
let thumb_height = (rect.height * viewport / total).clamp(min_thumb_height, rect.height);
let max_offset = self
.content_items
.saturating_sub(self.viewport_items.max(1));
let offset = self.scroll.offset.min(max_offset) as f32;
let progress = if max_offset == 0 {
0.0
} else {
offset / max_offset as f32
};
let thumb_y = rect.y + (rect.height - thumb_height).max(0.0) * progress;
ScrollbarModel {
track,
thumb: Rect::new(track.x, thumb_y, track.width, thumb_height),
visible: true,
}
}
pub fn shader_skin(&self) -> WidgetShaderSkin {
WidgetShaderSkin::empty(WidgetSurfaceKind::Scrollable)
.with_layer(WidgetShaderLayer::new(
WidgetShaderRole::Surface,
pane_descriptor(),
))
.with_layer(
WidgetShaderLayer::new(WidgetShaderRole::Rim, caustic_descriptor())
.with_intensity(0.32),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ScrollbarModel {
pub track: Rect,
pub thumb: Rect,
pub visible: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum TabPlacement {
Top,
Bottom,
Left,
Right,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TabItem {
pub id: String,
pub label: String,
pub badge: Option<String>,
pub disabled: bool,
}
impl TabItem {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: sanitize_widget_id(&id.into()),
label: sanitize_widget_label(&label.into(), 80),
badge: None,
disabled: false,
}
}
pub fn with_badge(mut self, badge: impl Into<String>) -> Self {
let badge = sanitize_widget_label(&badge.into(), 24);
self.badge = (!badge.is_empty()).then_some(badge);
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TabBarModel {
pub id: String,
pub tabs: Vec<TabItem>,
pub active_index: usize,
pub placement: TabPlacement,
pub rect: Rect,
}
impl TabBarModel {
pub fn new(id: impl Into<String>, tabs: impl IntoIterator<Item = TabItem>) -> Self {
let tabs = tabs
.into_iter()
.take(WIDGET_TAB_MAX_ITEMS)
.collect::<Vec<_>>();
let active_index = tabs.iter().position(|tab| !tab.disabled).unwrap_or(0);
Self {
id: sanitize_widget_id(&id.into()),
tabs,
active_index,
placement: TabPlacement::Top,
rect: Rect::ZERO,
}
}
pub fn with_rect(mut self, rect: Rect) -> Self {
self.rect = sanitize_rect(rect);
self
}
pub fn with_placement(mut self, placement: TabPlacement) -> Self {
self.placement = placement;
self
}
pub fn active(&self) -> Option<&TabItem> {
self.tabs
.get(self.active_index)
.filter(|tab| !tab.disabled)
.or_else(|| self.tabs.iter().find(|tab| !tab.disabled))
}
pub fn set_active(&mut self, id: &str) -> bool {
let id = sanitize_widget_id(id);
if let Some(index) = self
.tabs
.iter()
.position(|tab| tab.id == id && !tab.disabled)
{
self.active_index = index;
true
} else {
false
}
}
pub fn move_active(&mut self, delta: isize) -> Option<&TabItem> {
let enabled = self
.tabs
.iter()
.enumerate()
.filter_map(|(index, tab)| (!tab.disabled).then_some(index))
.collect::<Vec<_>>();
if enabled.is_empty() {
self.active_index = 0;
return None;
}
let current = enabled
.iter()
.position(|index| *index == self.active_index)
.unwrap_or(0);
let shift = delta.rem_euclid(enabled.len() as isize) as usize;
let next = enabled[(current + shift) % enabled.len()];
self.active_index = next;
self.tabs.get(next)
}
pub fn slots(&self) -> Vec<TabSlot> {
let rect = sanitize_rect(self.rect);
let count = self.tabs.len().clamp(1, WIDGET_TAB_MAX_ITEMS);
let horizontal = matches!(self.placement, TabPlacement::Top | TabPlacement::Bottom);
let active_id = self.active().map(|tab| tab.id.as_str());
self.tabs
.iter()
.take(WIDGET_TAB_MAX_ITEMS)
.enumerate()
.map(|(index, tab)| {
let slot = if horizontal {
let width = rect.width / count as f32;
Rect::new(rect.x + width * index as f32, rect.y, width, rect.height)
} else {
let height = rect.height / count as f32;
Rect::new(rect.x, rect.y + height * index as f32, rect.width, height)
};
TabSlot {
id: tab.id.clone(),
label: tab.label.clone(),
rect: slot,
active: active_id == Some(tab.id.as_str()),
disabled: tab.disabled,
}
})
.collect()
}
pub fn shader_skin(&self) -> WidgetShaderSkin {
WidgetShaderSkin::empty(WidgetSurfaceKind::Tabs)
.with_layer(WidgetShaderLayer::new(
WidgetShaderRole::Surface,
pane_descriptor(),
))
.with_layer(
WidgetShaderLayer::new(WidgetShaderRole::Interaction, ripple_descriptor())
.with_intensity(0.36),
)
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TabSlot {
pub id: String,
pub label: String,
pub rect: Rect,
pub active: bool,
pub disabled: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PaneKind {
Editor,
Transcript,
Inspector,
Sidebar,
Terminal,
Preview,
Custom,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PaneModel {
pub id: String,
pub title: String,
pub kind: PaneKind,
pub rect: Rect,
pub min_size_px: f32,
pub collapsed: bool,
pub closable: bool,
pub scroll: ScrollState,
pub tabs: TabBarModel,
}
impl PaneModel {
pub fn new(id: impl Into<String>, title: impl Into<String>, kind: PaneKind) -> Self {
let id = sanitize_widget_id(&id.into());
Self {
tabs: TabBarModel::new(format!("{id}.tabs"), [TabItem::new("main", "Main")]),
id,
title: sanitize_widget_label(&title.into(), 96),
kind,
rect: Rect::ZERO,
min_size_px: 160.0,
collapsed: false,
closable: true,
scroll: ScrollState::new(),
}
}
pub fn with_tabs(mut self, tabs: TabBarModel) -> Self {
self.tabs = tabs;
self
}
pub fn with_scroll(mut self, scroll: ScrollState) -> Self {
self.scroll = scroll;
self
}
pub fn with_rect(mut self, rect: Rect) -> Self {
self.rect = sanitize_rect(rect);
self
}
pub fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
pub fn closable(mut self, closable: bool) -> Self {
self.closable = closable;
self
}
pub fn with_min_size(mut self, min_size_px: f32) -> Self {
self.min_size_px = if min_size_px.is_finite() {
min_size_px.clamp(48.0, 4096.0)
} else {
160.0
};
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PaneLayoutMode {
Stack,
SplitHorizontal,
SplitVertical,
Grid,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DynamicPaneSet {
pub panes: Vec<PaneModel>,
pub active_index: usize,
pub mode: PaneLayoutMode,
pub rect: Rect,
pub gap_px: f32,
}
impl DynamicPaneSet {
pub fn new(panes: impl IntoIterator<Item = PaneModel>) -> Self {
Self {
panes: panes.into_iter().take(WIDGET_PANE_MAX_ITEMS).collect(),
active_index: 0,
mode: PaneLayoutMode::SplitHorizontal,
rect: Rect::ZERO,
gap_px: 12.0,
}
}
pub fn with_rect(mut self, rect: Rect) -> Self {
self.rect = sanitize_rect(rect);
self
}
pub fn with_mode(mut self, mode: PaneLayoutMode) -> Self {
self.mode = mode;
self
}
pub fn with_gap(mut self, gap_px: f32) -> Self {
self.gap_px = if gap_px.is_finite() {
gap_px.clamp(0.0, 96.0)
} else {
0.0
};
self
}
pub fn active(&self) -> Option<&PaneModel> {
self.panes
.get(self.active_index)
.filter(|pane| !pane.collapsed)
.or_else(|| self.panes.iter().find(|pane| !pane.collapsed))
}
pub fn set_active(&mut self, id: &str) -> bool {
let id = sanitize_widget_id(id);
if let Some(index) = self
.panes
.iter()
.position(|pane| pane.id == id && !pane.collapsed)
{
self.active_index = index;
true
} else {
false
}
}
pub fn push(&mut self, pane: PaneModel) {
if self.panes.len() < WIDGET_PANE_MAX_ITEMS {
self.panes.push(pane);
}
}
pub fn close(&mut self, id: &str) -> bool {
let id = sanitize_widget_id(id);
if let Some(index) = self
.panes
.iter()
.position(|pane| pane.id == id && pane.closable)
{
self.panes.remove(index);
self.active_index = self.active_index.min(self.panes.len().saturating_sub(1));
true
} else {
false
}
}
pub fn layout(&self) -> Vec<PaneModel> {
let rect = sanitize_rect(self.rect);
let visible = self
.panes
.iter()
.take(WIDGET_PANE_MAX_ITEMS)
.filter(|pane| !pane.collapsed)
.collect::<Vec<_>>();
if rect.is_empty() || visible.is_empty() {
return Vec::new();
}
match self.mode {
PaneLayoutMode::Stack => {
let active_id = self.active().map(|pane| pane.id.as_str());
visible
.into_iter()
.map(|pane| {
let mut pane = pane.clone();
pane.rect = if active_id == Some(pane.id.as_str()) {
rect
} else {
Rect::ZERO
};
pane
})
.collect()
}
PaneLayoutMode::SplitHorizontal => split_panes(visible, rect, self.gap_px, true),
PaneLayoutMode::SplitVertical => split_panes(visible, rect, self.gap_px, false),
PaneLayoutMode::Grid => grid_panes(visible, rect, self.gap_px),
}
}
}
impl Default for DynamicPaneSet {
fn default() -> Self {
Self::new(std::iter::empty())
}
}
fn split_panes(
visible: Vec<&PaneModel>,
rect: Rect,
gap_px: f32,
horizontal: bool,
) -> Vec<PaneModel> {
let count = visible.len().max(1);
let gap = gap_px.clamp(0.0, 96.0);
let total_gap = gap * count.saturating_sub(1) as f32;
visible
.into_iter()
.enumerate()
.map(|(index, pane)| {
let mut pane = pane.clone();
pane.rect = if horizontal {
let width = ((rect.width - total_gap) / count as f32).max(0.0);
Rect::new(
rect.x + (width + gap) * index as f32,
rect.y,
width,
rect.height,
)
} else {
let height = ((rect.height - total_gap) / count as f32).max(0.0);
Rect::new(
rect.x,
rect.y + (height + gap) * index as f32,
rect.width,
height,
)
};
pane
})
.collect()
}
fn grid_panes(visible: Vec<&PaneModel>, rect: Rect, gap_px: f32) -> Vec<PaneModel> {
let count = visible.len().max(1);
let columns = (count as f32).sqrt().ceil() as usize;
let rows = count.div_ceil(columns).max(1);
let gap = gap_px.clamp(0.0, 96.0);
let cell_width =
((rect.width - gap * columns.saturating_sub(1) as f32) / columns as f32).max(0.0);
let cell_height = ((rect.height - gap * rows.saturating_sub(1) as f32) / rows as f32).max(0.0);
visible
.into_iter()
.enumerate()
.map(|(index, pane)| {
let column = index % columns;
let row = index / columns;
let mut pane = pane.clone();
pane.rect = Rect::new(
rect.x + (cell_width + gap) * column as f32,
rect.y + (cell_height + gap) * row as f32,
cell_width,
cell_height,
);
pane
})
.collect()
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct OverlayLayer {
pub id: String,
pub kind: OverlayKind,
pub title: String,
pub rect: Rect,
pub modal: bool,
pub dismissible: bool,
pub z_index: i16,
pub visible: bool,
}
impl OverlayLayer {
pub fn new(
id: impl Into<String>,
kind: OverlayKind,
title: impl Into<String>,
rect: Rect,
) -> Self {
Self {
id: sanitize_widget_id(&id.into()),
kind,
title: sanitize_widget_label(&title.into(), 96),
rect: sanitize_rect(rect),
modal: false,
dismissible: true,
z_index: 0,
visible: true,
}
}
pub fn modal(mut self, modal: bool) -> Self {
self.modal = modal;
self
}
pub fn dismissible(mut self, dismissible: bool) -> Self {
self.dismissible = dismissible;
self
}
pub fn with_z_index(mut self, z_index: i16) -> Self {
self.z_index = z_index;
self
}
pub fn contains(&self, point: Vec2) -> bool {
self.visible && self.rect.contains(point.x, point.y)
}
pub fn shader_skin(&self) -> WidgetShaderSkin {
WidgetShaderSkin::empty(WidgetSurfaceKind::Overlay)
.with_layer(WidgetShaderLayer::new(
WidgetShaderRole::Surface,
pane_descriptor(),
))
.with_layer(
WidgetShaderLayer::new(WidgetShaderRole::Rim, bloom_descriptor())
.with_intensity(if self.modal { 0.82 } else { 0.48 }),
)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct OverlayStack {
pub layers: Vec<OverlayLayer>,
}
impl OverlayStack {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, layer: OverlayLayer) {
if self.layers.len() < WIDGET_OVERLAY_MAX_ITEMS {
self.layers.push(layer);
}
}
pub fn visible_layers(&self) -> Vec<&OverlayLayer> {
let mut layers = self
.layers
.iter()
.take(WIDGET_OVERLAY_MAX_ITEMS)
.enumerate()
.filter(|(_, layer)| layer.visible)
.collect::<Vec<_>>();
layers.sort_by_key(|(index, layer)| (layer.z_index, *index));
layers.into_iter().map(|(_, layer)| layer).collect()
}
pub fn top(&self) -> Option<&OverlayLayer> {
self.visible_layers().into_iter().last()
}
pub fn hit_test(&self, point: Vec2) -> Option<&OverlayLayer> {
self.visible_layers()
.into_iter()
.rev()
.find(|layer| layer.contains(point))
}
pub fn dismiss_top(&mut self) -> Option<OverlayLayer> {
let index = self
.layers
.iter()
.enumerate()
.filter(|(_, layer)| layer.visible && layer.dismissible)
.max_by_key(|(index, layer)| (layer.z_index, *index))
.map(|(index, _)| index)?;
Some(self.layers.remove(index))
}
pub fn blocks_background(&self) -> bool {
self.visible_layers().into_iter().any(|layer| layer.modal)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetWorkbench {
pub panes: DynamicPaneSet,
pub overlays: OverlayStack,
pub tabs: Vec<TabBarModel>,
pub controls: Vec<WidgetControl>,
pub scroll_areas: Vec<ScrollAreaModel>,
}
impl WidgetWorkbench {
pub fn new() -> Self {
Self::default()
}
pub fn with_panes(mut self, panes: DynamicPaneSet) -> Self {
self.panes = panes;
self
}
pub fn push_control(&mut self, control: WidgetControl) {
if self.controls.len() < WIDGET_CONTROL_MAX_ITEMS {
self.controls.push(control);
}
}
pub fn push_tab_bar(&mut self, tabs: TabBarModel) {
if self.tabs.len() < WIDGET_TAB_MAX_ITEMS {
self.tabs.push(tabs);
}
}
pub fn push_overlay(&mut self, overlay: OverlayLayer) {
self.overlays.push(overlay);
}
pub fn push_scroll_area(&mut self, area: ScrollAreaModel) {
if self.scroll_areas.len() < WIDGET_SCROLL_AREA_MAX_ITEMS {
self.scroll_areas.push(area);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetSurfaceKind {
Pane,
Prompt,
Transcript,
StatusChip,
Inspector,
CommandPalette,
Control,
Scrollable,
Tabs,
Overlay,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetShaderRole {
Surface,
Rim,
Interaction,
Content,
Energy,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WidgetShaderLayer {
pub role: WidgetShaderRole,
pub shader: ShaderDescriptor,
pub intensity: f32,
}
impl WidgetShaderLayer {
pub fn new(role: WidgetShaderRole, shader: ShaderDescriptor) -> Self {
Self {
role,
shader,
intensity: 1.0,
}
}
pub fn with_intensity(mut self, intensity: f32) -> Self {
self.intensity = sanitize_intensity(intensity);
self
}
pub fn estimated_complexity(self) -> f32 {
self.shader.complexity as f32 * sanitize_intensity(self.intensity).max(0.1)
}
}
fn sanitize_intensity(intensity: f32) -> f32 {
if intensity.is_finite() {
intensity.clamp(0.0, 4.0)
} else {
0.0
}
}
fn sanitize_unit(value: f32, fallback: f32) -> f32 {
let fallback = if fallback.is_finite() { fallback } else { 0.0 };
if value.is_finite() {
value.clamp(0.0, 1.0)
} else {
fallback.clamp(0.0, 1.0)
}
}
fn sanitize_px(value: f32, min: f32, max: f32, fallback: f32) -> f32 {
let fallback = if fallback.is_finite() {
fallback.clamp(min, max)
} else {
min
};
if value.is_finite() {
value.clamp(min, max)
} else {
fallback
}
}
fn sanitize_angle(value: f32) -> f32 {
if value.is_finite() {
value.rem_euclid(std::f32::consts::TAU)
} else {
0.0
}
}
fn parse_slider_value(value: &str) -> f32 {
value
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.clamp(0.0, 1.0))
.unwrap_or(0.0)
}
fn format_slider_value(value: f32) -> String {
format!("{:.4}", sanitize_unit(value, 0.0))
}
fn sanitize_delta(delta: f32) -> f32 {
if delta.is_finite() {
delta
} else {
0.0
}
}
fn sanitize_widget_id(input: &str) -> String {
sanitize_str(input, 120)
.trim()
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
.take(120)
.collect()
}
fn sanitize_widget_label(input: &str, max_width: usize) -> String {
sanitize_title(input, max_width).trim().to_string()
}
fn sanitize_choice_item(mut item: WidgetChoiceItem) -> WidgetChoiceItem {
item.id = sanitize_widget_id(&item.id);
item.label = sanitize_widget_label(&item.label, 96);
item
}
fn first_enabled_index(items: &[WidgetChoiceItem]) -> Option<usize> {
items.iter().position(|item| !item.disabled)
}
fn sanitize_rect(rect: Rect) -> Rect {
if rect.is_finite() && rect.width >= 0.0 && rect.height >= 0.0 {
rect
} else {
Rect::ZERO
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WidgetShaderSkin {
pub surface: WidgetSurfaceKind,
pub layers: [Option<WidgetShaderLayer>; WIDGET_SHADER_SKIN_MAX_LAYERS],
pub layer_count: usize,
}
impl WidgetShaderSkin {
pub const MAX_LAYERS: usize = WIDGET_SHADER_SKIN_MAX_LAYERS;
pub const fn empty(surface: WidgetSurfaceKind) -> Self {
Self {
surface,
layers: [None; Self::MAX_LAYERS],
layer_count: 0,
}
}
pub fn with_layer(self, layer: WidgetShaderLayer) -> Self {
let mut compacted = Self::empty(self.surface);
for existing in self.layers().copied() {
compacted.layers[compacted.layer_count] = Some(existing);
compacted.layer_count += 1;
}
if compacted.layer_count < Self::MAX_LAYERS {
compacted.layers[compacted.layer_count] = Some(layer);
compacted.layer_count += 1;
}
compacted
}
pub fn len(&self) -> usize {
self.layers().count()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn remaining_capacity(&self) -> usize {
Self::MAX_LAYERS.saturating_sub(self.len())
}
pub fn primary_layer(&self) -> Option<&WidgetShaderLayer> {
self.layers().next()
}
pub fn layers(&self) -> impl Iterator<Item = &WidgetShaderLayer> {
self.layers[..self.slot_count()].iter().flatten()
}
pub fn has_animated_layers(&self) -> bool {
self.layers().any(|layer| layer.shader.animated)
}
pub fn estimated_complexity(&self) -> f32 {
self.layers()
.map(|layer| layer.estimated_complexity())
.sum()
}
pub fn within_complexity_budget(self, max_complexity: f32) -> Self {
let budget = if max_complexity.is_finite() {
max_complexity.max(0.0)
} else {
0.0
};
let mut total = 0.0;
let mut reduced = Self::empty(self.surface);
for (index, layer) in self.layers().copied().enumerate() {
let cost = layer.estimated_complexity();
if index == 0 || total + cost <= budget {
reduced = reduced.with_layer(layer);
total += cost;
}
}
reduced
}
fn slot_count(&self) -> usize {
self.layer_count.min(Self::MAX_LAYERS)
}
}
impl PaneChrome {
pub fn shader_skin(&self) -> WidgetShaderSkin {
let focus_layer = if self.focused {
WidgetShaderLayer::new(WidgetShaderRole::Interaction, bloom_descriptor())
.with_intensity(0.84)
} else {
WidgetShaderLayer::new(WidgetShaderRole::Rim, caustic_descriptor()).with_intensity(0.42)
};
WidgetShaderSkin::empty(WidgetSurfaceKind::Pane)
.with_layer(WidgetShaderLayer::new(
WidgetShaderRole::Surface,
pane_descriptor(),
))
.with_layer(focus_layer)
}
}
impl PromptModel {
pub fn shader_skin(&self) -> WidgetShaderSkin {
let activity = if self.is_empty() { 0.44 } else { 0.92 };
WidgetShaderSkin::empty(WidgetSurfaceKind::Prompt)
.with_layer(
WidgetShaderLayer::new(WidgetShaderRole::Surface, prompt_descriptor())
.with_intensity(activity),
)
.with_layer(
WidgetShaderLayer::new(WidgetShaderRole::Energy, laser_descriptor())
.with_intensity(activity * 0.72),
)
}
}
impl TranscriptItem {
pub fn shader_skin(&self) -> WidgetShaderSkin {
let role = match self.role.as_str() {
"assistant" => WidgetShaderRole::Content,
"user" => WidgetShaderRole::Interaction,
_ => WidgetShaderRole::Surface,
};
WidgetShaderSkin::empty(WidgetSurfaceKind::Transcript)
.with_layer(WidgetShaderLayer::new(role, code_lens_descriptor()).with_intensity(0.58))
}
}
impl StatusChip {
pub fn shader_skin(&self) -> WidgetShaderSkin {
WidgetShaderSkin::empty(WidgetSurfaceKind::StatusChip)
.with_layer(WidgetShaderLayer::new(
WidgetShaderRole::Surface,
aura_descriptor(),
))
.with_layer(
WidgetShaderLayer::new(WidgetShaderRole::Interaction, ripple_descriptor())
.with_intensity(0.46),
)
}
}
fn descriptor(
source: crate::shader::ShaderSource,
family: ShaderFamily,
description: &'static str,
complexity: u8,
) -> ShaderDescriptor {
ShaderDescriptor {
source,
family,
description,
complexity,
animated: true,
}
}
fn pane_descriptor() -> ShaderDescriptor {
descriptor(
pane_shader(),
ShaderFamily::Panel,
"Widget pane surface baked with hex lattice, scanline rim, and glass depth.",
4,
)
}
fn prompt_descriptor() -> ShaderDescriptor {
descriptor(
prompt_shader(),
ShaderFamily::Prompt,
"Prompt input surface with beam-field energy and caret activity.",
3,
)
}
fn laser_descriptor() -> ShaderDescriptor {
descriptor(
aisling_laser_etch_shader(),
ShaderFamily::Prompt,
"Prompt accent layer using laser etch and sweep timing.",
5,
)
}
fn bloom_descriptor() -> ShaderDescriptor {
descriptor(
bloom_glow_shader(),
ShaderFamily::Interaction,
"Focused widget glow field for hover and keyboard focus states.",
4,
)
}
fn caustic_descriptor() -> ShaderDescriptor {
descriptor(
glass_caustic_shader(),
ShaderFamily::Panel,
"Glass caustic rim layer for unfocused elevated panes.",
5,
)
}
fn code_lens_descriptor() -> ShaderDescriptor {
descriptor(
code_lens_shader(),
ShaderFamily::Code,
"Transcript and code content lens shimmer attached to row models.",
5,
)
}
fn aura_descriptor() -> ShaderDescriptor {
descriptor(
google_aura_shader(),
ShaderFamily::Interaction,
"Status chip aura layer for activity and assistant states.",
6,
)
}
fn ripple_descriptor() -> ShaderDescriptor {
descriptor(
ripple_shader(),
ShaderFamily::Interaction,
"Status chip interaction ripple layer.",
4,
)
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct StatusChip {
pub label: String,
pub value: String,
pub color: Rgba,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetSection {
pub title: String,
pub lines: Vec<String>,
pub accent: Rgba,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetFrame {
pub panes: Vec<PaneChrome>,
pub prompt: PromptModel,
pub transcript: Vec<TranscriptItem>,
pub chips: Vec<StatusChip>,
pub sections: Vec<WidgetSection>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_model_uses_placeholder_when_empty() {
let prompt = PromptModel {
label: String::new(),
input: String::new(),
cursor: 10,
placeholder: "Type here".into(),
};
assert_eq!(prompt.label(), "prompt");
assert!(prompt.is_empty());
assert!(prompt.showing_placeholder());
assert_eq!(prompt.display_text(), "Type here");
assert_eq!(prompt.clamped_cursor(), 0);
assert_eq!(prompt.cursor_byte_index(), 0);
}
#[test]
fn prompt_cursor_clamps_to_utf8_character_boundaries() {
let mut prompt = PromptModel {
label: "prompt".into(),
input: "a\u{00e9}\u{1f642}z".into(),
cursor: 3,
placeholder: String::new(),
};
assert_eq!(prompt.clamped_cursor(), 3);
assert_eq!(prompt.cursor_byte_index(), "a\u{00e9}\u{1f642}".len());
prompt.cursor = 99;
assert_eq!(prompt.clamped_cursor(), 4);
assert_eq!(prompt.cursor_byte_index(), prompt.input.len());
}
#[test]
fn prompt_model_inserts_and_deletes_utf8_text() {
let mut prompt = PromptModel {
label: "prompt".into(),
input: "az".into(),
cursor: 1,
placeholder: String::new(),
};
assert!(prompt.insert_text("\u{00e9}\u{1f642}"));
assert_eq!(prompt.input, "a\u{00e9}\u{1f642}z");
assert_eq!(prompt.cursor, 3);
assert!(prompt.backspace());
assert_eq!(prompt.input, "a\u{00e9}z");
assert_eq!(prompt.cursor, 2);
prompt.move_cursor(-1);
assert!(prompt.delete_forward());
assert_eq!(prompt.input, "az");
assert_eq!(prompt.cursor, 1);
}
#[test]
fn prompt_model_replaces_and_deletes_selections() {
let mut prompt = PromptModel {
label: "prompt".into(),
input: "hello world".into(),
cursor: 999,
placeholder: String::new(),
};
let selection = TextSelection::new(6, 11);
assert_eq!(prompt.selected_text(selection), "world");
assert!(prompt.replace_selection(selection, "rae"));
assert_eq!(prompt.input, "hello rae");
assert_eq!(prompt.cursor, 9);
assert!(prompt.delete_selection(TextSelection::new(0, 5)));
assert_eq!(prompt.input, " rae");
assert_eq!(prompt.cursor, 0);
}
#[test]
fn prompt_model_sanitizes_inserted_text_and_caps_width() {
let mut prompt = PromptModel {
label: "prompt".into(),
input: "ready".into(),
cursor: 5,
placeholder: String::new(),
};
assert!(prompt.insert_text("\n\u{200f}\x1b[31mgo"));
assert_eq!(prompt.input, "ready go");
prompt.input = "x".repeat(PROMPT_INPUT_MAX_WIDTH + 100);
prompt.cursor = usize::MAX;
assert!(!prompt.insert_text("more"));
assert_eq!(str_display_width(&prompt.input), PROMPT_INPUT_MAX_WIDTH);
assert_eq!(prompt.cursor, prompt.input_char_count());
}
#[test]
fn prompt_model_moves_by_words_and_clamps_extreme_deltas() {
let mut prompt = PromptModel {
label: "prompt".into(),
input: "one two three".into(),
cursor: 0,
placeholder: String::new(),
};
prompt.move_cursor_to_next_word();
assert_eq!(prompt.cursor, 4);
prompt.move_cursor_to_next_word();
assert_eq!(prompt.cursor, 9);
prompt.move_cursor_to_previous_word();
assert_eq!(prompt.cursor, 4);
prompt.move_cursor(isize::MAX);
assert_eq!(prompt.cursor, prompt.input_char_count());
prompt.move_cursor(isize::MIN);
assert_eq!(prompt.cursor, 0);
}
#[test]
fn pane_shader_skin_bakes_glsl_layers_into_widget_model() {
let pane = PaneChrome {
id: PaneId::Main,
title: "Main".into(),
subtitle: String::new(),
rect: Rect::new(0.0, 0.0, 100.0, 80.0),
focused: true,
};
let skin = pane.shader_skin();
let layers = skin.layers().collect::<Vec<_>>();
assert_eq!(skin.surface, WidgetSurfaceKind::Pane);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].shader.source.name, "rae-pane-hex-lattice");
assert_eq!(layers[1].role, WidgetShaderRole::Interaction);
assert!(skin.has_animated_layers());
assert!(skin.estimated_complexity() > 6.0);
}
#[test]
fn prompt_shader_skin_reflects_empty_and_active_intensity() {
let empty = PromptModel {
label: "prompt".into(),
input: String::new(),
cursor: 0,
placeholder: "Type".into(),
};
let active = PromptModel {
input: "ship it".into(),
..empty.clone()
};
let empty_complexity = empty.shader_skin().estimated_complexity();
let active_skin = active.shader_skin();
assert_eq!(active_skin.layer_count, 2);
assert!(active_skin.estimated_complexity() > empty_complexity);
assert!(active_skin
.layers()
.any(|layer| layer.shader.source.name == "rae-aisling-laser-etch"));
}
#[test]
fn transcript_and_status_shader_skins_expose_expected_layers() {
let transcript = TranscriptItem::assistant("done", "detail").shader_skin();
let status = StatusChip {
label: "state".into(),
value: "ok".into(),
color: Rgba::WHITE,
}
.shader_skin();
assert_eq!(transcript.surface, WidgetSurfaceKind::Transcript);
assert_eq!(transcript.len(), 1);
assert_eq!(
transcript.primary_layer().unwrap().role,
WidgetShaderRole::Content
);
assert_eq!(
transcript.primary_layer().unwrap().shader.source.name,
"rae-code-lens-scan"
);
assert_eq!(status.surface, WidgetSurfaceKind::StatusChip);
assert_eq!(status.len(), 2);
assert!(status
.layers()
.any(|layer| layer.shader.source.name == "rae-google-aura"));
assert!(status
.layers()
.any(|layer| layer.shader.source.name == "rae-interaction-ripples"));
}
#[test]
fn shader_layer_intensity_sanitizes_non_finite_public_values() {
let layer = WidgetShaderLayer::new(WidgetShaderRole::Surface, pane_descriptor())
.with_intensity(f32::NAN);
let malformed = WidgetShaderLayer {
intensity: f32::INFINITY,
..layer
};
assert_eq!(layer.intensity, 0.0);
assert!(malformed.estimated_complexity().is_finite());
assert_eq!(malformed.estimated_complexity(), 0.4);
}
#[test]
fn shader_skin_ignores_layers_beyond_fixed_capacity() {
let layer = WidgetShaderLayer::new(WidgetShaderRole::Surface, pane_descriptor());
let skin = WidgetShaderSkin::empty(WidgetSurfaceKind::Pane)
.with_layer(layer)
.with_layer(layer)
.with_layer(layer)
.with_layer(layer)
.with_layer(layer);
assert_eq!(skin.layer_count, WIDGET_SHADER_SKIN_MAX_LAYERS);
assert_eq!(skin.layers().count(), WIDGET_SHADER_SKIN_MAX_LAYERS);
}
#[test]
fn shader_skin_accessors_recover_from_malformed_public_layer_count() {
let malformed = WidgetShaderSkin {
surface: WidgetSurfaceKind::Pane,
layers: [None; WIDGET_SHADER_SKIN_MAX_LAYERS],
layer_count: usize::MAX,
};
let layer = WidgetShaderLayer::new(WidgetShaderRole::Surface, pane_descriptor());
let repaired = malformed.with_layer(layer);
assert!(malformed.is_empty());
assert_eq!(malformed.layers().count(), 0);
assert_eq!(repaired.len(), 1);
assert_eq!(repaired.layer_count, 1);
}
#[test]
fn shader_skin_budget_keeps_primary_layer_and_drops_expensive_extras() {
let skin = PromptModel {
label: "prompt".into(),
input: "active".into(),
cursor: 0,
placeholder: String::new(),
}
.shader_skin();
let reduced = skin.within_complexity_budget(1.0);
assert_eq!(reduced.len(), 1);
assert_eq!(
reduced.remaining_capacity(),
WIDGET_SHADER_SKIN_MAX_LAYERS - 1
);
assert_eq!(
reduced.primary_layer().unwrap().shader.source.name,
"rae-prompt-beam-field"
);
assert!(reduced.estimated_complexity() < skin.estimated_complexity());
}
#[test]
fn widget_size_metrics_are_ordered_and_sanitized() {
let compact = WidgetSize::Compact.metrics();
let spacious = WidgetSize::Spacious.metrics();
let malformed = WidgetMetrics {
min_height_px: f32::NAN,
padding_x_px: -4.0,
padding_y_px: f32::INFINITY,
gap_px: 999.0,
radius_px: -8.0,
border_width_px: f32::NAN,
}
.sanitized();
assert!(compact.min_height_px < spacious.min_height_px);
assert!(compact.radius_px < spacious.radius_px);
assert_eq!(malformed.min_height_px, 36.0);
assert_eq!(malformed.padding_x_px, 0.0);
assert_eq!(malformed.padding_y_px, 8.0);
assert_eq!(malformed.gap_px, 96.0);
assert_eq!(malformed.radius_px, 0.0);
assert_eq!(malformed.border_width_px, 1.0);
}
#[test]
fn widget_style_descriptor_resolves_state_and_applies_overrides_last() {
let style = WidgetStyleDescriptor::new(WidgetVariant::Glass, WidgetSize::Compact)
.with_state(WidgetState::new().hovered(true).focused(true))
.with_accent(Rgba::rgb8(120, 180, 255))
.with_overrides(
WidgetStyleOverride::empty()
.with_fill(Rgba::rgb8(255, 0, 0))
.with_opacity(2.0)
.with_radius_px(44.0)
.with_shader_intensity(1.8),
)
.resolve();
assert_eq!(style.variant, WidgetVariant::Glass);
assert_eq!(style.size, WidgetSize::Compact);
assert_eq!(style.fill, Rgba::rgb8(255, 0, 0));
assert_eq!(style.opacity, 1.0);
assert_eq!(style.metrics.radius_px, 44.0);
assert_eq!(style.shader_intensity, 1.8);
assert!(style.elevation_px > 7.0);
}
#[test]
fn widget_control_resolved_style_merges_control_state_and_accent() {
let control = WidgetControl::new("run", WidgetControlKind::Button, "Run")
.disabled(true)
.selected(true)
.with_accent(Rgba::new(f32::NAN, 2.0, -1.0, 1.0));
let style = control.resolved_style(
WidgetStyleDescriptor::new(WidgetVariant::Filled, WidgetSize::Regular)
.with_state(WidgetState::new().hovered(true)),
);
assert!(style.state.hovered);
assert!(style.state.disabled);
assert!(style.state.selected);
assert_eq!(style.accent.to_array(), [0.0, 1.0, 0.0, 1.0]);
assert_eq!(style.opacity, 0.44);
assert_eq!(style.shader_intensity, 0.18);
}
#[test]
fn widget_control_status_resolves_pointer_focus_and_selection() {
let control = WidgetControl::new("run", WidgetControlKind::Button, "Run")
.with_rect(Rect::new(0.0, 0.0, 80.0, 32.0));
let selected = control.clone().selected(true);
let disabled = control.clone().disabled(true);
assert_eq!(
control.interaction_status(Vec2::new(8.0, 8.0), false, false),
WidgetInteractionStatus::Hovered
);
assert_eq!(
control.interaction_status(Vec2::new(8.0, 8.0), true, false),
WidgetInteractionStatus::Pressed
);
assert_eq!(
selected.interaction_status(Vec2::new(100.0, 100.0), false, true),
WidgetInteractionStatus::Active
);
assert_eq!(
disabled.interaction_status(Vec2::new(8.0, 8.0), true, true),
WidgetInteractionStatus::Disabled
);
assert!(control
.state_at(Vec2::new(8.0, 8.0), true, false)
.status()
.is_interactive());
}
#[test]
fn widget_control_activation_performs_basic_control_behavior() {
let mut checkbox = WidgetControl::new("check", WidgetControlKind::Checkbox, "Check");
let mut toggle = WidgetControl::new("toggle", WidgetControlKind::Toggle, "Toggle");
let mut label = WidgetControl::new("label", WidgetControlKind::Label, "Label");
let mut input = WidgetControl::new("input", WidgetControlKind::TextInput, "Input");
let mut row = WidgetControl::new("row", WidgetControlKind::ListItem, "Row");
assert!(checkbox.activate());
assert!(checkbox.is_checked());
assert!(checkbox.activate());
assert!(!checkbox.is_checked());
assert!(toggle.activate());
assert!(toggle.selected);
assert!(!label.activate());
assert!(input.set_value("hello\u{200f}\x1b[31m"));
assert_eq!(input.value, "hello");
input.disabled = true;
assert!(!input.set_value("blocked"));
assert_eq!(input.value, "hello");
assert!(row.activate());
assert!(row.selected);
}
#[test]
fn widget_control_slider_values_are_bounded_and_disabled_safe() {
let mut slider =
WidgetControl::new("level", WidgetControlKind::Slider, "Level").with_value("NaN");
let mut button = WidgetControl::new("button", WidgetControlKind::Button, "Button");
assert_eq!(slider.slider_value(), Some(0.0));
assert!(slider.set_slider_value(1.8));
assert_eq!(slider.value, "1.0000");
assert_eq!(slider.slider_value(), Some(1.0));
assert!(slider.adjust_slider_value(-0.25));
assert_eq!(slider.slider_value(), Some(0.75));
assert!(!button.adjust_slider_value(0.5));
slider.disabled = true;
assert!(!slider.set_slider_value(0.2));
assert_eq!(slider.slider_value(), Some(0.75));
}
#[test]
fn widget_choice_list_skips_disabled_items_and_wraps() {
let mut choices = WidgetChoiceList::new([
WidgetChoiceItem::new("off", "Off").disabled(true),
WidgetChoiceItem::new("low", "Low"),
WidgetChoiceItem::new("high", "High"),
]);
assert_eq!(choices.selected_id(), Some("low"));
assert_eq!(
choices.select_next().map(|item| item.id.as_str()),
Some("high")
);
assert_eq!(
choices.select_next().map(|item| item.id.as_str()),
Some("low")
);
assert_eq!(
choices.select_previous().map(|item| item.id.as_str()),
Some("high")
);
assert!(!choices.set_selected_id("off"));
assert!(choices.set_selected_index(1));
choices.selected_index = Some(usize::MAX);
assert_eq!(choices.selected_id(), Some("low"));
assert_eq!(
choices
.move_selection(isize::MAX)
.map(|item| item.id.as_str()),
Some("high")
);
}
#[test]
fn widget_choice_list_sanitizes_and_caps_public_items() {
let choices =
WidgetChoiceList::new((0..(WIDGET_CHOICE_MAX_ITEMS + 10)).map(|index| {
WidgetChoiceItem::new(format!("item.{index}\u{200f}\x1b[31m"), "Label")
}));
assert_eq!(choices.items.len(), WIDGET_CHOICE_MAX_ITEMS);
assert_eq!(choices.items[0].id, "item.0");
assert_eq!(choices.selected_id(), Some("item.0"));
let mut disabled = WidgetChoiceList::new([
WidgetChoiceItem::new("one", "One").disabled(true),
WidgetChoiceItem::new("two", "Two").disabled(true),
]);
assert_eq!(disabled.selected(), None);
assert_eq!(disabled.move_selection(1), None);
}
#[test]
fn malformed_public_widget_style_overrides_are_sanitized() {
let malformed = WidgetStyleOverride {
fill: Some(Rgba::new(f32::NAN, 2.0, -1.0, f32::INFINITY)),
stroke: None,
foreground: None,
glow: None,
opacity: Some(f32::NAN),
elevation_px: Some(f32::INFINITY),
radius_px: Some(f32::INFINITY),
border_width_px: Some(-10.0),
shader_intensity: Some(f32::NAN),
};
let style = WidgetResolvedStyle::resolve(
WidgetVariant::Tonal,
WidgetSize::Regular,
WidgetState::new().pressed(true),
Rgba::rgb8(122, 185, 255),
)
.with_overrides(malformed);
assert_eq!(style.fill.to_array(), [0.0, 1.0, 0.0, 0.0]);
assert_eq!(style.opacity, 1.0);
assert_eq!(style.elevation_px, 0.0);
assert_eq!(style.metrics.radius_px, 14.0);
assert_eq!(style.metrics.border_width_px, 0.0);
assert_eq!(style.shader_intensity, 0.0);
let public_style = WidgetResolvedStyle {
fill: Rgba::new(f32::NAN, 2.0, -1.0, f32::INFINITY),
opacity: f32::INFINITY,
elevation_px: f32::NAN,
shader_intensity: f32::INFINITY,
metrics: WidgetMetrics {
min_height_px: f32::NAN,
padding_x_px: -10.0,
padding_y_px: f32::INFINITY,
gap_px: 999.0,
radius_px: -1.0,
border_width_px: f32::NAN,
},
..style
}
.sanitized();
assert_eq!(public_style.fill.to_array(), [0.0, 1.0, 0.0, 0.0]);
assert_eq!(public_style.opacity, 1.0);
assert_eq!(public_style.elevation_px, 0.0);
assert_eq!(public_style.shader_intensity, 0.0);
assert_eq!(public_style.metrics.min_height_px, 36.0);
assert_eq!(public_style.metrics.padding_x_px, 0.0);
}
#[test]
fn widget_material_quality_controls_glass_cost_and_fallback() {
let style = WidgetResolvedStyle::resolve(
WidgetVariant::Glass,
WidgetSize::Regular,
WidgetState::new().focused(true),
Rgba::rgb8(122, 185, 255),
);
let high = style.material(WidgetMaterialDescriptor::liquid_glass());
let fallback = style.material(
WidgetMaterialDescriptor::liquid_glass().with_quality(WidgetMaterialQuality::Fallback),
);
assert_eq!(high.kind, WidgetMaterialKind::LiquidGlass);
assert_eq!(high.quality, WidgetMaterialQuality::High);
assert!(high.blur_px > fallback.blur_px);
assert!(high.refraction > fallback.refraction);
assert!(high.saturation > fallback.saturation);
assert!(high.shadow.blur_px > 0.0);
assert_eq!(fallback.blur_px, 0.0);
assert_eq!(fallback.refraction, 0.0);
assert_eq!(fallback.shadow, WidgetMaterialShadow::none());
assert!(fallback.fallback_fill.a > 0.0);
}
#[test]
fn widget_material_descriptor_sanitizes_public_values() {
let style = WidgetResolvedStyle::resolve(
WidgetVariant::Tonal,
WidgetSize::Regular,
WidgetState::new().hovered(true),
Rgba::rgb8(122, 185, 255),
);
let descriptor = WidgetMaterialDescriptor {
kind: WidgetMaterialKind::Glass,
quality: WidgetMaterialQuality::High,
blur_px: 999.0,
saturation: f32::NAN,
refraction: 2.0,
thickness_px: 999.0,
rim_intensity: f32::NAN,
specular_intensity: f32::INFINITY,
light_angle_rad: f32::NAN,
shadow: WidgetMaterialShadow {
color: Rgba::new(f32::NAN, 2.0, -1.0, f32::INFINITY),
offset: Vec2::new(f32::NAN, f32::INFINITY),
blur_px: 999.0,
spread_px: -999.0,
},
};
let material = descriptor.resolve(style);
assert_eq!(material.blur_px, 96.0);
assert_eq!(material.saturation, 1.0);
assert_eq!(material.refraction, 1.0);
assert_eq!(material.thickness_px, 24.0);
assert_eq!(material.rim_intensity, 0.0);
assert_eq!(material.specular_intensity, 0.0);
assert_eq!(material.light_angle_rad, 0.0);
assert_eq!(material.shadow.color.to_array(), [0.0, 1.0, 0.0, 0.0]);
assert_eq!(material.shadow.offset, Vec2::ZERO);
assert_eq!(material.shadow.blur_px, 256.0);
assert_eq!(material.shadow.spread_px, -64.0);
}
#[test]
fn elevated_material_uses_style_opacity_and_shadow() {
let disabled_style = WidgetResolvedStyle::resolve(
WidgetVariant::Tonal,
WidgetSize::Compact,
WidgetState::new().disabled(true),
Rgba::rgb8(122, 185, 255),
);
let material = disabled_style.material(WidgetMaterialDescriptor::elevated(8.0));
assert_eq!(material.kind, WidgetMaterialKind::Elevated);
assert_eq!(material.opacity, 0.44);
assert!(material.fill.a <= 0.44);
assert!(material.shadow.blur_px > 0.0);
assert!(material.shader_intensity <= disabled_style.shader_intensity);
}
#[test]
fn scroll_area_exposes_visible_range_and_scrollbar_geometry() {
let mut area = ScrollAreaModel::new("log", Rect::new(0.0, 0.0, 240.0, 120.0), 100, 10);
area.scroll_by(30);
let bar = area.scrollbar();
assert_eq!(area.visible_range(), 30..40);
assert!(bar.visible);
assert!(bar.track.is_finite());
assert!(bar.thumb.height < bar.track.height);
assert_eq!(area.shader_skin().surface, WidgetSurfaceKind::Scrollable);
}
#[test]
fn tab_bar_skips_disabled_tabs_and_lays_out_slots() {
let mut tabs = TabBarModel::new(
"main-tabs",
[
TabItem::new("files", "Files").disabled(true),
TabItem::new("preview", "Preview").with_badge("2"),
TabItem::new("logs", "Logs"),
],
)
.with_rect(Rect::new(0.0, 0.0, 300.0, 36.0));
assert_eq!(tabs.active().unwrap().id, "preview");
assert!(tabs.set_active("logs"));
assert_eq!(tabs.move_active(1).unwrap().id, "preview");
assert_eq!(tabs.move_active(2).unwrap().id, "preview");
assert_eq!(tabs.move_active(3).unwrap().id, "logs");
tabs.active_index = 0;
let slots = tabs.slots();
assert_eq!(slots.len(), 3);
assert_eq!(slots[0].rect.width, 100.0);
assert!(!slots[0].active);
assert!(slots[1].active);
assert_eq!(tabs.shader_skin().surface, WidgetSurfaceKind::Tabs);
}
#[test]
fn dynamic_pane_set_lays_out_and_closes_panes() {
let mut panes = DynamicPaneSet::new([
PaneModel::new("editor", "Editor", PaneKind::Editor),
PaneModel::new("preview", "Preview", PaneKind::Preview),
PaneModel::new("terminal", "Terminal", PaneKind::Terminal).collapsed(true),
])
.with_rect(Rect::new(0.0, 0.0, 620.0, 300.0))
.with_mode(PaneLayoutMode::SplitHorizontal)
.with_gap(20.0);
let layout = panes.layout();
assert_eq!(layout.len(), 2);
assert_eq!(layout[0].rect.width, 300.0);
assert_eq!(layout[1].rect.x, 320.0);
assert!(panes.set_active("preview"));
assert_eq!(panes.active().unwrap().id, "preview");
assert!(panes.close("preview"));
assert_eq!(panes.panes.len(), 2);
}
#[test]
fn stacked_panes_show_first_visible_pane_when_active_is_collapsed() {
let rect = Rect::new(0.0, 0.0, 320.0, 220.0);
let panes = DynamicPaneSet::new([
PaneModel::new("hidden", "Hidden", PaneKind::Inspector).collapsed(true),
PaneModel::new("editor", "Editor", PaneKind::Editor),
])
.with_rect(rect)
.with_mode(PaneLayoutMode::Stack);
let layout = panes.layout();
assert_eq!(layout.len(), 1);
assert_eq!(layout[0].id, "editor");
assert_eq!(layout[0].rect, rect);
}
#[test]
fn overlay_stack_orders_hits_and_dismisses_top_layer() {
let mut overlays = OverlayStack::new();
overlays.push(OverlayLayer::new(
"help",
OverlayKind::Help,
"Help",
Rect::new(0.0, 0.0, 200.0, 200.0),
));
overlays.push(
OverlayLayer::new(
"palette",
OverlayKind::CommandPalette,
"Command Palette",
Rect::new(20.0, 20.0, 220.0, 160.0),
)
.modal(true)
.with_z_index(10),
);
assert!(overlays.blocks_background());
assert_eq!(overlays.top().unwrap().id, "palette");
assert_eq!(
overlays.hit_test(Vec2::new(24.0, 24.0)).unwrap().id,
"palette"
);
assert_eq!(overlays.dismiss_top().unwrap().id, "palette");
assert_eq!(overlays.top().unwrap().id, "help");
}
#[test]
fn widget_workbench_caps_controls_and_exposes_control_skins() {
let mut workbench =
WidgetWorkbench::new().with_panes(DynamicPaneSet::new([PaneModel::new(
"editor",
"Editor",
PaneKind::Editor,
)]));
for index in 0..(WIDGET_CONTROL_MAX_ITEMS + 10) {
workbench.push_control(
WidgetControl::new(format!("button.{index}"), WidgetControlKind::Button, "Run")
.selected(index == 0)
.with_rect(Rect::new(0.0, 0.0, 80.0, 32.0)),
);
}
assert_eq!(workbench.controls.len(), WIDGET_CONTROL_MAX_ITEMS);
assert!(workbench.controls[0].contains(Vec2::new(8.0, 8.0)));
assert_eq!(
workbench.controls[0].shader_skin().surface,
WidgetSurfaceKind::Control
);
}
}