Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions crates/authoring/fission-widgets/src/circular_progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ use std::f32::consts::PI;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CircularProgress {
/// Stable identity for widget-owned animations.
///
/// Indeterminate progress registers a repeating rotation only when this id
/// is provided; without it, the widget renders the static indicator arc.
pub id: Option<fission_core::WidgetNodeId>,
pub value: Option<f32>, // 0.0 to 1.0. If None, indeterminate (spinner).
pub size: f32,
pub color: Option<Color>,
Expand All @@ -16,6 +21,7 @@ pub struct CircularProgress {
impl Default for CircularProgress {
fn default() -> Self {
Self {
id: None,
value: None,
size: 40.0,
color: None,
Expand All @@ -26,12 +32,12 @@ impl Default for CircularProgress {
}

impl<S: fission_core::AppState> Widget<S> for CircularProgress {
fn build(&self, _ctx: &mut BuildCtx<S>, view: &View<S>) -> Node {
fn build(&self, ctx: &mut BuildCtx<S>, view: &View<S>) -> Node {
let tokens = &view.env.theme.tokens;
let color = self.color.unwrap_or(tokens.colors.primary);
let track_color = self.track_color.unwrap_or(tokens.colors.border);

Node::Custom(fission_core::ui::CustomNode {
let custom_node = Node::Custom(fission_core::ui::CustomNode {
debug_tag: "CircularProgress".into(),
lowerer: Some(std::sync::Arc::new(CircularProgressLowerer {
value: self.value,
Expand All @@ -41,7 +47,28 @@ impl<S: fission_core::AppState> Widget<S> for CircularProgress {
thickness: self.thickness,
})),
render_object: None,
})
});

if self.value.is_none() {
if let Some(id) = self.id {
ctx.anim_for(id).request(fission_core::AnimationRequest {
property: fission_core::AnimationPropertyId::Rotation,
from: fission_core::AnimationStartValue::Explicit(0.0),
to: 2.0 * std::f32::consts::PI,
duration_ms: 1000,
repeat: true,
delay_ms: 0,
frame_interval_ms: Some(16),
easing: Default::default(),
});
return fission_core::ui::Composite::new(custom_node)
.repaint_boundary(true)
.animated_rotation(id, 0.0)
.into_node();
}
}

custom_node
}
}

Expand Down Expand Up @@ -90,9 +117,9 @@ impl LowerDyn for CircularProgressLowerer {
)
.build(cx);

// Value Arc
let val = self.value.unwrap_or(0.25); // Default 25% for indeterminate (should animate rotation)
// TODO: Indeterminate animation rotation.
// Value Arc. Indeterminate progress draws a quarter arc; when the
// widget has a stable id, build() attaches the repeating rotation.
let val = self.value.unwrap_or(0.25);

let angle = val * 2.0 * PI;
// Arc from -PI/2 (top) to -PI/2 + angle.
Expand Down
21 changes: 10 additions & 11 deletions crates/authoring/fission-widgets/src/date_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ pub struct DatePicker {
pub value: Option<NaiveDate>,
pub is_open: bool,
pub width: Option<f32>,
pub view_year: Option<i32>,
pub view_month: Option<u32>,
pub on_navigate: Option<Arc<dyn Fn(i32, u32) -> ActionEnvelope + Send + Sync>>,
pub on_change: Option<Arc<dyn Fn(NaiveDate) -> ActionEnvelope + Send + Sync>>,
pub on_toggle: Option<ActionEnvelope>,
pub on_close: Option<ActionEnvelope>,
Expand Down Expand Up @@ -79,22 +82,18 @@ impl<S: fission_core::AppState> Widget<S> for DatePicker {
let today = chrono::Local::now().date_naive();
let display_date = self.value.unwrap_or(today);

// The visible month is controlled by the parent, separate from the
// selected date. That lets callers browse months without mutating
// the committed value until a day is selected.
Box::new(
Calendar {
year: display_date.year(),
month: display_date.month(),
year: self.view_year.unwrap_or(display_date.year()),
month: self.view_month.unwrap_or(display_date.month()),
selected_date: self.value,
on_select: self.on_change.clone(), // When selected, close? logic handles that
on_navigate: None, // TODO: Wiring navigation state requires DatePicker to own month state?
on_select: self.on_change.clone(),
on_navigate: self.on_navigate.clone(),
cell_size: None,
padding: None,
// Yes, DatePicker needs `view_month` state separate from `value`.
// For MVP, we navigate relative to `value` or `today`.
// Calendar needs `on_navigate` to update `view_month`.
// DatePicker doesn't store `view_month` in this struct.
// It relies on AppState.
// User must provide `view_month` in AppState?
// Yes, standard Fission pattern.
}
.build(_ctx, view),
)
Expand Down
6 changes: 6 additions & 0 deletions crates/authoring/fission-widgets/src/date_range_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ impl<S: fission_core::AppState> Widget<S> for DateRangePicker {
value: self.start,
is_open: self.is_start_open,
width: None,
view_year: None,
view_month: None,
on_navigate: None,
on_change: cb.clone().map(|f| {
Arc::new(move |d| f(Some(d), e))
as Arc<dyn Fn(NaiveDate) -> ActionEnvelope + Send + Sync>
Expand All @@ -57,6 +60,9 @@ impl<S: fission_core::AppState> Widget<S> for DateRangePicker {
value: self.end,
is_open: self.is_end_open,
width: None,
view_year: None,
view_month: None,
on_navigate: None,
on_change: cb.map(|f| {
Arc::new(move |d| f(s, Some(d)))
as Arc<dyn Fn(NaiveDate) -> ActionEnvelope + Send + Sync>
Expand Down
74 changes: 55 additions & 19 deletions crates/authoring/fission-widgets/src/drawer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use fission_core::{ActionEnvelope, BuildCtx, View, Widget, WidgetNodeId};
use serde::{Deserialize, Serialize};

/// The edge from which a [`Drawer`] slides out.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum DrawerSide {
Left,
Right,
Expand Down Expand Up @@ -34,16 +34,6 @@ pub struct Drawer {

impl<S: fission_core::AppState> Widget<S> for Drawer {
fn build(&self, ctx: &mut BuildCtx<S>, view: &View<S>) -> Node {
// Animation logic
// We want to animate the transform (TranslateX).
// If open: 0. If closed: -width (Left) or +width (Right).
// BUT we typically unmount the portal when closed.
// To support exit animation, we need the portal to stay mounted but be off-screen?
// Or we just snap for MVP.
// Let's implement simple enter animation if is_open changes from false to true.
// Since Widget `build` is stateless (re-run), we rely on `view` state.
// But `is_open` is passed in.

if !self.is_open {
return fission_core::ui::widgets::Spacer::default().into_node();
}
Expand All @@ -57,8 +47,10 @@ impl<S: fission_core::AppState> Widget<S> for Drawer {
};
let width = self.width.unwrap_or(300.0).min(max_panel_width);

// Backdrop
let backdrop = GestureDetector {
// The drawer only mounts while open, so these are enter animations.
// Exit animation would need a retained closing state owned above this
// stateless widget.
let backdrop_inner = GestureDetector {
on_tap: self.on_dismiss.clone(),
child: Box::new(
Container::new(fission_core::ui::widgets::Spacer::default().into_node())
Expand All @@ -75,11 +67,27 @@ impl<S: fission_core::AppState> Widget<S> for Drawer {
}
.into_node();

let backdrop_anim_id = WidgetNodeId::from_u128(self.id.as_u128() ^ 0xBACD_u128);
ctx.anim_for(backdrop_anim_id)
.request(fission_core::AnimationRequest {
property: fission_core::AnimationPropertyId::Opacity,
from: fission_core::AnimationStartValue::Explicit(0.0),
to: 1.0,
duration_ms: 200,
repeat: false,
delay_ms: 0,
frame_interval_ms: None,
easing: Default::default(),
});
let backdrop = fission_core::ui::Composite::new(backdrop_inner)
.repaint_boundary(true)
.animated_opacity(backdrop_anim_id, 0.0)
.into_node();

// Drawer Content
let content_node = Container::new(*self.content.clone())
.bg(tokens.colors.surface)
.width(width)
// Height fills parent (Positioned top/bottom 0)
.shadow(tokens.elevations.level3.unwrap_or(BoxShadow {
color: Color {
r: 0,
Expand All @@ -93,14 +101,37 @@ impl<S: fission_core::AppState> Widget<S> for Drawer {
.padding_all(0.0)
.into_node();

let slide_anim_id = WidgetNodeId::from_u128(self.id.as_u128() ^ 0xD00D_u128);
let slide_start = match self.side {
DrawerSide::Left => -width,
DrawerSide::Right => width,
};
// Start explicitly off-screen; relying on the current animation value
// would make the first open frame snap to the final position.
ctx.anim_for(slide_anim_id)
.request(fission_core::AnimationRequest {
property: fission_core::AnimationPropertyId::TranslateX,
from: fission_core::AnimationStartValue::Explicit(slide_start),
to: 0.0,
duration_ms: 250,
repeat: false,
delay_ms: 0,
frame_interval_ms: None,
easing: Default::default(),
});
let animated_content = fission_core::ui::Composite::new(content_node)
.repaint_boundary(true)
.animated_translate_x(slide_anim_id, slide_start)
.into_node();

let positioned_content = match self.side {
DrawerSide::Left => fission_core::ui::Positioned {
left: Some(0.0),
top: Some(0.0),
bottom: Some(0.0),
right: None,
width: Some(width),
child: Some(Box::new(content_node)),
child: Some(Box::new(animated_content)),
..Default::default()
},
DrawerSide::Right => fission_core::ui::Positioned {
Expand All @@ -109,14 +140,12 @@ impl<S: fission_core::AppState> Widget<S> for Drawer {
bottom: Some(0.0),
left: None,
width: Some(width),
child: Some(Box::new(content_node)),
child: Some(Box::new(animated_content)),
..Default::default()
},
}
.into_node();

// TODO: slide animation for drawer open/close

let root = ZStack {
children: vec![
fission_core::ui::Positioned {
Expand All @@ -139,7 +168,14 @@ impl<S: fission_core::AppState> Widget<S> for Drawer {
right: Some(0.0),
top: Some(0.0),
bottom: Some(0.0),
child: Some(Box::new(root)),
child: Some(Box::new(
fission_core::ui::widgets::FocusScope {
id: None,
is_barrier: true,
children: vec![root],
}
.into_node(),
)),
..Default::default()
}
.into_node();
Expand Down
9 changes: 8 additions & 1 deletion crates/authoring/fission-widgets/src/modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,14 @@ impl<S: fission_core::AppState> Widget<S> for Modal {
right: Some(0.0),
top: Some(0.0),
bottom: Some(0.0),
child: Some(Box::new(root)),
child: Some(Box::new(
fission_core::ui::widgets::FocusScope {
id: None,
is_barrier: true,
children: vec![root],
}
.into_node(),
)),
..Default::default()
}
.into_node();
Expand Down
14 changes: 10 additions & 4 deletions crates/authoring/fission-widgets/src/number_input.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use crate::Icon;
use fission_core::ui::{Button, ButtonVariant, Container, Node, Row, TextInput};
use fission_core::ui::{
widgets::text_input::TextInputChangePayload, Button, ButtonVariant, Container, Node, Row,
TextInput,
};
use fission_core::{ActionEnvelope, BuildCtx, NodeId, View, Widget, WidgetNodeId};
use fission_icons::material;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -81,9 +84,12 @@ impl<S: fission_core::AppState> Widget<S> for NumberInput {
value: display_text,
width: Some(field_width),
borderless: true,
// TODO: Parse text input back to float for on_change
// Needs `on_change` logic similar to slider?
// MVP: Just display value.
// NumberInput owns a numeric action contract; the
// keyboard hint alone must not change generic text
// input payloads.
keyboard_type: fission_ir::semantics::TextInputType::Number,
change_payload: TextInputChangePayload::Number,
on_change: self.on_change.clone(),
..Default::default()
}
.into_node(),
Expand Down
25 changes: 24 additions & 1 deletion crates/core/fission-core/src/hit_test.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::env::ScrollStateMap;
use crate::ui::custom_render::downcast_render_object;
use fission_diagnostics::prelude as diag;
use fission_ir::{CoreIR, LayoutOp, NodeId, Op};
use fission_ir::{CoreIR, LayoutOp, NodeId, Op, PaintOp};
use fission_layout::{LayoutPoint, LayoutSnapshot};
use glam::{Mat4, Vec4};

Expand Down Expand Up @@ -123,6 +123,10 @@ fn hit_test_recursive(
}
}

if geom.rect.contains(point) && paint_op_blocks_hit_testing(&node.op) {
return Some(node_id);
}

let mut current_is_hit = false;
if geom.rect.contains(point) {
match &node.op {
Expand Down Expand Up @@ -150,6 +154,25 @@ fn hit_test_recursive(
}
}

fn paint_op_blocks_hit_testing(op: &Op) -> bool {
match op {
Op::Paint(PaintOp::DrawRect {
fill,
stroke,
shadow,
..
}) => fill.is_some() || stroke.is_some() || shadow.is_some(),
Op::Paint(PaintOp::DrawText { text, .. }) => !text.is_empty(),
Op::Paint(PaintOp::DrawRichText { runs, .. }) => {
runs.iter().any(|run| !run.text.is_empty())
}
Op::Paint(PaintOp::DrawImage { .. }) => true,
Op::Paint(PaintOp::DrawPath { fill, stroke, .. })
| Op::Paint(PaintOp::DrawSvg { fill, stroke, .. }) => fill.is_some() || stroke.is_some(),
_ => false,
}
}

pub fn find_next_focus_node(ir: &CoreIR, current: Option<NodeId>, reverse: bool) -> Option<NodeId> {
// Identify current scope if focused node is provided
let (current_scope_id, current_is_barrier) = if let Some(id) = current {
Expand Down
27 changes: 20 additions & 7 deletions crates/core/fission-core/src/input/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1744,13 +1744,26 @@ impl TextInputController {
new_text: String,
) {
Self::persist_runtime_state(ctx, node_id);
if let Some(action_entry) = semantics
.actions
.entries
.iter()
.find(|e| e.trigger == fission_ir::semantics::ActionTrigger::Change)
{
let payload = serde_json::to_vec(&new_text).unwrap();
if let Some(action_entry) = semantics.actions.entries.iter().find(|e| {
matches!(
e.trigger,
fission_ir::semantics::ActionTrigger::Change
| fission_ir::semantics::ActionTrigger::NumberChange
)
}) {
let payload = match action_entry.trigger {
fission_ir::semantics::ActionTrigger::Change => serde_json::to_vec(&new_text)
.expect("serializing text input change payload should not fail"),
fission_ir::semantics::ActionTrigger::NumberChange => {
let Ok(parsed) = new_text.trim().parse::<f32>() else {
return;
};
serde_json::to_vec(&parsed)
.expect("serializing numeric text input payload should not fail")
}
_ => unreachable!("filtered to text input change triggers"),
};

let envelope = ActionEnvelope {
id: ActionId::from_u128(action_entry.action_id),
payload,
Expand Down
Loading
Loading