Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
examples/calculator/src/main.rs
1use std::any::Any;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4use strato_core::event::{Event, EventResult, MouseEvent};
5use strato_core::inspector::{inspector, InspectorConfig};
6use strato_core::state::Signal;
7use strato_core::types::{Color, Point, Rect, Transform};
8use strato_platform::{
9 init::{InitBuilder, InitConfig},
10 ApplicationBuilder, WindowBuilder,
11};
12use strato_sdk::prelude::*;
13use strato_widgets::animation::{AnimationController, Curve};
14use strato_widgets::{text::TextAlign, Flex, InspectorOverlay};
15 
16#[derive(Clone, Debug)]
17enum Operation {
18 Add,
19 Subtract,
20 Multiply,
21 Divide,
22}
23 
24#[derive(Clone, Debug)]
25struct CalculatorState {
26 display: Signal<String>,
27 expression: Signal<String>, // Shows full operation like "12 + 5"
28 history: Signal<Vec<String>>, // Stores past calculations
29 current_value: f64,
30 previous_value: f64,
31 operation: Option<Operation>,
32 waiting_for_operand: bool,
33}
34 
35impl Default for CalculatorState {
36 fn default() -> Self {
37 Self {
38 display: Signal::new("0".to_string()),
39 expression: Signal::new("".to_string()),
40 history: Signal::new(Vec::new()),
41 current_value: 0.0,
42 previous_value: 0.0,
43 operation: None,
44 waiting_for_operand: false,
45 }
46 }
47}
48 
49impl CalculatorState {
50 fn input_digit(&mut self, digit: u8) {
51 if self.waiting_for_operand {
52 self.display.set(digit.to_string());
53 self.waiting_for_operand = false;
54 } else {
55 let current = self.display.get();
56 if current == "0" {
57 self.display.set(digit.to_string());
58 } else {
59 self.display.update(|s| s.push_str(&digit.to_string()));
60 }
61 }
62 
63 // Update expression
64 // If we just finished an operation (equals), start over unless an operator was pressed
65 // For simplicity in this step, we just append to expression if it's part of the current number
66 // A more robust approach updates expression based entirely on state
67 
68 self.current_value = self.display.get().parse().unwrap_or(0.0);
69 }
70 
71 fn input_decimal(&mut self) {
72 if self.waiting_for_operand {
73 self.display.set("0.".to_string());
74 self.waiting_for_operand = false;
75 } else if !self.display.get().contains('.') {
76 self.display.update(|s| s.push('.'));
77 }
78 }
79 
80 fn clear(&mut self) {
81 self.display.set("0".to_string());
82 self.expression.set("".to_string());
83 self.current_value = 0.0;
84 self.previous_value = 0.0;
85 self.operation = None;
86 self.waiting_for_operand = false;
87 }
88 
89 fn perform_operation(&mut self, next_operation: Option<Operation>) {
90 if let Some(op) = &self.operation {
91 let result = match op {
92 Operation::Add => self.previous_value + self.current_value,
93 Operation::Subtract => self.previous_value - self.current_value,
94 Operation::Multiply => self.previous_value * self.current_value,
95 Operation::Divide => {
96 if self.current_value != 0.0 {
97 self.previous_value / self.current_value
98 } else {
99 f64::NAN
100 }
101 }
102 };
103 
104 self.current_value = result;
105 let display_val = if result.is_nan() {
106 "Error".to_string()
107 } else if result.fract() == 0.0 && result.abs() < 1e10 {
108 format!("{}", result as i64)
109 } else {
110 format!("{:.8}", result)
111 .trim_end_matches('0')
112 .trim_end_matches('.')
113 .to_string()
114 };
115 self.display.set(display_val.clone());
116 
117 // If equals was pressed (next_operation is None)
118 if next_operation.is_none() {
119 let op_str = match op {
120 Operation::Add => "+",
121 Operation::Subtract => "-",
122 Operation::Multiply => "×",
123 Operation::Divide => "÷",
124 };
125 let history_entry = format!(
126 "{} {} {} = {}",
127 self.format_number(self.previous_value),
128 op_str,
129 self.format_number(self.current_value), // This is actually the 2nd operand before result, but we overwrote it. Needs fix separately or simplified logic.
130 display_val
131 );
132 // Correction: We overwrote current_value with result. We should have stored 2nd operand.
133 // For now let's just push " = result" logic or simplify.
134 // Let's implement a better input tracking.
135 
136 // IMPROVED LOGIC:
137 // We need to track the operands better for the history string.
138 // But strictly following the plan: track history.
139 
140 // Let's construct history string properly.
141 // We'll rely on expression updates in a dedicated manner in a future step if needed,
142 // but here let's set expression to empty or result.
143 self.expression.set("".to_string());
144 
145 // Add to history
146 self.history.update(|h| {
147 h.push(history_entry);
148 if h.len() > 5 {
149 h.remove(0);
150 } // Keep last 5
151 });
152 }
153 }
154 
155 if let Some(ref next_op) = next_operation {
156 let op_symbol = match next_op {
157 Operation::Add => "+",
158 Operation::Subtract => "-",
159 Operation::Multiply => "×",
160 Operation::Divide => "÷",
161 };
162 // Update expression display: "Result + "
163 let current_display = self.display.get();
164 self.expression
165 .set(format!("{} {}", current_display, op_symbol));
166 }
167 
168 self.waiting_for_operand = true;
169 self.operation = next_operation;
170 self.previous_value = self.current_value;
171 }
172 
173 fn format_number(&self, num: f64) -> String {
174 if num.fract() == 0.0 {
175 format!("{}", num as i64)
176 } else {
177 format!("{}", num)
178 }
179 }
180}
181 
182fn main() -> anyhow::Result<()> {
183 // Use the new initialization system with optimized font loading
184 let config = InitConfig {
185 enable_logging: true,
186 skip_problematic_fonts: true,
187 max_font_faces: Some(25), // Even more restrictive for calculator
188 ..Default::default()
189 };
190 
191 InitBuilder::new().with_config(config).init_all()?;
192 
193 inspector().configure(InspectorConfig {
194 enabled: true,
195 ..Default::default()
196 });
197 
198 println!("Calculator - StratoUI initialized with font optimizations!");
199 
200 // Build and run the application
201 ApplicationBuilder::new()
202 .title("StratoUI Calculator")
203 .window(
204 WindowBuilder::new()
205 .with_size(320.0, 480.0)
206 .resizable(false),
207 )
208 .run(InspectorOverlay::new(build_calculator_ui()))
209}
210 
211fn build_calculator_ui() -> impl Widget {
212 let state = Arc::new(Mutex::new(CalculatorState::default()));
213 
214 Container::new()
215 .background(Color::rgb(0.0, 0.0, 0.0)) // Pure black background
216 .padding(15.0) // Increased padding
217 .child(
218 Column::new()
219 .spacing(12.0) // Increased spacing
220 .children(vec![
221 // Display
222 Box::new(create_display(state.clone())),
223 // Button grid (main block)
224 Box::new(create_button_grid(state.clone())),
225 // Button grid (bottom row with span)
226 Box::new(create_bottom_row(state.clone())),
227 ]),
228 )
229}
230 
231fn create_display(state: Arc<Mutex<CalculatorState>>) -> impl Widget {
232 let (display_signal, expression_signal, history_signal) = {
233 let state = state.lock().unwrap();
234 (
235 state.display.clone(),
236 state.expression.clone(),
237 state.history.clone(),
238 )
239 };
240 
241 // We Wrap the Container in a Flex to ensure it takes full width of the parent Column/Container
242 // effectively pushing the alignment to the far right.
243 Flex::new(Box::new(
244 Container::new()
245 .background(Color::BLACK) // Match main background
246 // .padding(15.0) // padding handled by parent or here?
247 // Parent has 15.0 padding. This container is inside that.
248 // If we want text to align to the very right edge of this container,
249 // we need this container to be as wide as possible.
250 // The Flex below with .flex(1.0) on the *Container* itself (if Container implemented FlexItem traits directly)
251 // or we use Flex widget.
252 .height(150.0)
253 .child(Column::new().spacing(4.0).children(vec![
254 // History
255 Box::new(
256 Text::new("")
257 .bind(history_signal.map(|h| h.join("\n")))
258 .size(16.0)
259 .color(Color::rgb(0.5, 0.5, 0.5))
260 .align(TextAlign::Right)
261 ),
262 Box::new(Flex::new(Box::new(
263 Container::new().height(10.0)
264 )).flex(1.0)),
265 // Current Expression
266 Box::new(
267 Text::new("")
268 .bind(expression_signal)
269 .size(24.0)
270 .color(Color::rgb(0.8, 0.8, 0.8))
271 .align(TextAlign::Right)
272 ),
273 // Main Display
274 Box::new(
275 Text::new("")
276 .bind(display_signal)
277 .size(64.0)
278 .color(Color::rgb(1.0, 1.0, 1.0))
279 .align(TextAlign::Right)
280 ),
281 ])),
282 ))
283 .flex(1.0)
284}
285 
286fn create_button_grid(state: Arc<Mutex<CalculatorState>>) -> impl Widget {
287 let buttons = vec![
288 // Row 1
289 ("C", ButtonType::Clear),
290 ("±", ButtonType::PlusMinus),
291 ("%", ButtonType::Percent),
292 ("÷", ButtonType::Operation(Operation::Divide)),
293 // Row 2
294 ("7", ButtonType::Digit(7)),
295 ("8", ButtonType::Digit(8)),
296 ("9", ButtonType::Digit(9)),
297 ("×", ButtonType::Operation(Operation::Multiply)),
298 // Row 3
299 ("4", ButtonType::Digit(4)),
300 ("5", ButtonType::Digit(5)),
301 ("6", ButtonType::Digit(6)),
302 ("-", ButtonType::Operation(Operation::Subtract)),
303 // Row 4
304 ("1", ButtonType::Digit(1)),
305 ("2", ButtonType::Digit(2)),
306 ("3", ButtonType::Digit(3)),
307 ("+", ButtonType::Operation(Operation::Add)),
308 ];
309 
310 let grid_items: Vec<Box<dyn Widget>> = buttons
311 .into_iter()
312 .map(|(text, btn_type)| {
313 Box::new(AnimatedButton::new(text, btn_type, state.clone())) as Box<dyn Widget>
314 })
315 .collect();
316 
317 Grid::new()
318 .columns(vec![
319 GridUnit::Fraction(1.0),
320 GridUnit::Fraction(1.0),
321 GridUnit::Fraction(1.0),
322 GridUnit::Fraction(1.0),
323 ])
324 .row_gap(8.0)
325 .col_gap(8.0)
326 .children(grid_items)
327}
328 
329fn create_bottom_row(state: Arc<Mutex<CalculatorState>>) -> impl Widget {
330 Row::new().spacing(8.0).children(vec![
331 // Zero button - Spans 2 columns worth (50%)
332 Box::new(
333 Flex::new(Box::new(AnimatedButton::new(
334 "0",
335 ButtonType::Digit(0),
336 state.clone(),
337 )))
338 .flex(2.0),
339 ),
340 // Decimal
341 Box::new(
342 Flex::new(Box::new(AnimatedButton::new(
343 ".",
344 ButtonType::Decimal,
345 state.clone(),
346 )))
347 .flex(1.0),
348 ),
349 // Equals
350 Box::new(
351 Flex::new(Box::new(AnimatedButton::new(
352 "=",
353 ButtonType::Equals,
354 state.clone(),
355 )))
356 .flex(1.0),
357 ),
358 ])
359}
360 
361#[derive(Clone, Debug)]
362enum ButtonType {
363 Digit(u8),
364 Operation(Operation),
365 Decimal,
366 Equals,
367 Clear,
368 PlusMinus,
369 Percent,
370}
371 
372// Custom Animated Button Widget
373#[derive(Debug)]
374struct AnimatedButton {
375 id: WidgetId,
376 text: String,
377 button_type: ButtonType,
378 state: Arc<Mutex<CalculatorState>>,
379 anim_controller: AnimationController,
380 is_pressed: bool,
381 bounds: Arc<Mutex<Rect>>,
382}
383 
384impl AnimatedButton {
385 fn new(text: &str, button_type: ButtonType, state: Arc<Mutex<CalculatorState>>) -> Self {
386 let controller =
387 AnimationController::new(Duration::from_millis(100)).with_curve(Curve::EaseOut);
388 
389 Self {
390 id: strato_widgets::widget::generate_id(),
391 text: text.to_string(),
392 button_type,
393 state,
394 anim_controller: controller,
395 is_pressed: false,
396 bounds: Arc::new(Mutex::new(Rect::new(0.0, 0.0, 0.0, 0.0))),
397 }
398 }
399}
400 
401impl Widget for AnimatedButton {
402 fn id(&self) -> WidgetId {
403 self.id
404 }
405 
406 fn layout(
407 &mut self,
408 constraints: strato_core::layout::Constraints,
409 ) -> strato_core::layout::Size {
410 // Fill available space
411 strato_core::layout::Size::new(
412 constraints.max_width,
413 // 70.0 is roughly the height we want, but let's be flexible
414 constraints.max_height.min(70.0).max(50.0),
415 )
416 }
417 
418 fn render(
419 &self,
420 batch: &mut strato_renderer::batch::RenderBatch,
421 layout: strato_core::layout::Layout,
422 ) {
423 // Update bounds for hit testing in event handling
424 if let Ok(mut bounds) = self.bounds.lock() {
425 *bounds = Rect::new(
426 layout.position.x,
427 layout.position.y,
428 layout.size.width,
429 layout.size.height,
430 );
431 }
432 
433 let bg_color = match self.button_type {
434 ButtonType::Operation(_) | ButtonType::Equals => Color::rgb(1.0, 0.62, 0.04), // Orange (#FF9F0A)
435 ButtonType::Clear | ButtonType::PlusMinus | ButtonType::Percent => {
436 Color::rgb(0.65, 0.65, 0.65)
437 } // Light gray (#A5A5A5)
438 _ => Color::rgb(0.2, 0.2, 0.2), // Dark gray (#333333)
439 };
440 
441 let text_color = match self.button_type {
442 ButtonType::Clear | ButtonType::PlusMinus | ButtonType::Percent => {
443 Color::rgb(0.0, 0.0, 0.0)
444 } // Black text
445 _ => Color::rgb(1.0, 1.0, 1.0), // White text
446 };
447 
448 // Animation logic
449 let scale = if self.is_pressed {
450 0.95
451 } else {
452 // Check animation progress if releasing
453 let t = self.anim_controller.value();
454 if t < 1.0 {
455 0.95 + (0.05 * t) // rebound
456 } else {
457 1.0
458 }
459 };
460 
461 // Draw button shape (Circle or Pill)
462 let radius = layout.size.height.min(layout.size.width) / 2.0;
463 
464 // Apply scale from center
465 let center = layout.size.to_vec2() / 2.0;
466 let transform =
467 Transform::translate(layout.position.x + center.x, layout.position.y + center.y)
468 .combine(&Transform::scale(scale, scale))
469 .combine(&Transform::translate(-center.x, -center.y));
470 
471 if (layout.size.width - layout.size.height).abs() < 1.0 {
472 // Perfect circle (Square aspect ratio)
473 let center_pt = (
474 layout.position.x + layout.size.width / 2.0,
475 layout.position.y + layout.size.height / 2.0,
476 );
477 batch.add_circle(center_pt, radius, bg_color, 32, transform);
478 } else {
479 // Pill shape (Width > Height, e.g., '0' button)
480 // Left circle
481 let left_center = (layout.position.x + radius, layout.position.y + radius);
482 batch.add_circle(left_center, radius, bg_color, 32, transform);
483 
484 // Right circle
485 let right_center = (
486 layout.position.x + layout.size.width - radius,
487 layout.position.y + radius,
488 );
489 batch.add_circle(right_center, radius, bg_color, 32, transform);
490 
491 // Middle rect
492 let rect = Rect::new(
493 layout.position.x + radius,
494 layout.position.y,
495 layout.size.width - 2.0 * radius,
496 layout.size.height,
497 );
498 batch.add_rect(rect, bg_color, transform);
499 }
500 
501 // Draw text
502 let font_size = 32.0;
503 let text_x = layout.position.x + layout.size.width / 2.0;
504 let text_y = layout.position.y + layout.size.height / 2.0 - font_size / 2.0;
505 
506 batch.add_text_aligned(
507 self.text.clone(),
508 (text_x, text_y),
509 text_color,
510 font_size,
511 0.0,
512 strato_core::text::TextAlign::Center,
513 );
514 }
515 
516 fn handle_event(&mut self, event: &Event) -> EventResult {
517 match event {
518 Event::MouseDown(MouseEvent { position, .. }) => {
519 let bounds = *self.bounds.lock().unwrap();
520 let point = Point::new(position.x, position.y);
521 
522 if bounds.contains(point) {
523 self.is_pressed = true;
524 return EventResult::Handled;
525 }
526 }
527 Event::MouseUp(MouseEvent { position, .. }) => {
528 if self.is_pressed {
529 self.is_pressed = false;
530 self.anim_controller.reset();
531 self.anim_controller.start();
532 
533 // Check if still within bounds to trigger action (standard button behavior)
534 let bounds = *self.bounds.lock().unwrap();
535 let point = Point::new(position.x, position.y);
536 
537 if bounds.contains(point) {
538 // Perform action
539 handle_button_click(&self.button_type, self.state.clone());
540 }
541 return EventResult::Handled;
542 }
543 }
544 _ => {}
545 }
546 EventResult::Ignored
547 }
548 
549 fn as_any(&self) -> &dyn Any {
550 self
551 }
552 
553 fn as_any_mut(&mut self) -> &mut dyn Any {
554 self
555 }
556 
557 fn clone_widget(&self) -> Box<dyn Widget> {
558 Box::new(Self {
559 id: strato_widgets::widget::generate_id(),
560 text: self.text.clone(),
561 button_type: self.button_type.clone(),
562 state: self.state.clone(),
563 anim_controller: self.anim_controller.clone(),
564 is_pressed: self.is_pressed,
565 bounds: self.bounds.clone(),
566 })
567 }
568}
569 
570fn handle_button_click(button_type: &ButtonType, state: Arc<Mutex<CalculatorState>>) {
571 let mut state = state.lock().unwrap();
572 
573 match button_type {
574 ButtonType::Digit(digit) => {
575 state.input_digit(*digit);
576 }
577 ButtonType::Decimal => {
578 state.input_decimal();
579 }
580 ButtonType::Operation(op) => {
581 state.perform_operation(Some(op.clone()));
582 }
583 ButtonType::Equals => {
584 state.perform_operation(None);
585 }
586 ButtonType::Clear => {
587 state.clear();
588 }
589 ButtonType::PlusMinus => {
590 state.current_value = -state.current_value;
591 let val = if state.current_value.fract() == 0.0 {
592 format!("{}", state.current_value as i64)
593 } else {
594 format!("{}", state.current_value)
595 };
596 state.display.set(val);
597 }
598 ButtonType::Percent => {
599 state.current_value = state.current_value / 100.0;
600 state.display.set(format!("{}", state.current_value));
601 }
602 }
603 
604 println!("Calculator state: {:?}", *state);
605}
606