Skip to main content

calc_android/
calc.rs

1// Copyright 2024 the Xilem Authors
2// SPDX-License-Identifier: Apache-2.0
3
4//! A simple calculator example
5#![expect(clippy::cast_possible_truncation, reason = "Deferred: Noisy")]
6
7use masonry::properties::types::{AsUnit, CrossAxisAlignment, Length, MainAxisAlignment};
8use masonry::widgets::GridParams;
9use winit::dpi::LogicalSize;
10use winit::error::EventLoopError;
11use xilem::style::Style;
12use xilem::view::{
13    Flex, FlexSequence, FlexSpacer, GridExt, GridSequence, button, flex_row, grid, label,
14    sized_box, text_button,
15};
16use xilem::{Color, EventLoop, EventLoopBuilder, WidgetView, WindowOptions, Xilem, palette};
17
18#[derive(Copy, Clone)]
19enum MathOperator {
20    Add,
21    Subtract,
22    Multiply,
23    Divide,
24}
25
26impl MathOperator {
27    fn as_str(self) -> &'static str {
28        match self {
29            Self::Add => "+",
30            Self::Subtract => "\u{2212}",
31            Self::Multiply => "×",
32            Self::Divide => "÷",
33        }
34    }
35
36    fn perform_op(self, num1: f64, num2: f64) -> f64 {
37        match self {
38            Self::Add => num1 + num2,
39            Self::Subtract => num1 - num2,
40            Self::Multiply => num1 * num2,
41            Self::Divide => num1 / num2,
42        }
43    }
44}
45
46struct Calculator {
47    current_num_index: usize,
48    clear_current_entry_on_input: bool, // For instances of negation used on a result.
49    numbers: [String; 2],
50    result: Option<String>,
51    operation: Option<MathOperator>,
52}
53
54impl Calculator {
55    fn get_current_number(&self) -> String {
56        self.current_number().to_string()
57    }
58
59    fn current_number(&self) -> &str {
60        &self.numbers[self.current_num_index]
61    }
62
63    fn set_current_number(&mut self, new_num: String) {
64        self.numbers[self.current_num_index] = new_num;
65    }
66
67    fn clear_all(&mut self) {
68        self.current_num_index = 0;
69        self.result = None;
70        self.operation = None;
71        for num in self.numbers.iter_mut() {
72            *num = "".into();
73        }
74    }
75
76    fn clear_entry(&mut self) {
77        self.clear_current_entry_on_input = false;
78        if self.result.is_some() {
79            self.clear_all();
80            return;
81        }
82        self.set_current_number("".into());
83    }
84
85    fn on_entered_digit(&mut self, digit: &str) {
86        if self.result.is_some() {
87            self.clear_all();
88        } else if self.clear_current_entry_on_input {
89            self.clear_entry();
90        }
91        let mut num = self.get_current_number();
92        // Special case: Don't allow more than one decimal.
93        if digit == "." {
94            if num.contains('.') {
95                // invalid action
96                return;
97            }
98            // Make it so you don't end up with just a decimal point
99            if num.is_empty() {
100                num = "0".into();
101            }
102            num += ".";
103        } else if num == "0" || num.is_empty() {
104            num = digit.to_string();
105        } else {
106            num += digit;
107        }
108        self.set_current_number(num);
109    }
110
111    fn on_entered_operator(&mut self, operator: MathOperator) {
112        self.clear_current_entry_on_input = false;
113        if self.operation.is_some() && !self.numbers[1].is_empty() {
114            if self.result.is_none() {
115                // All info is there to create a result, so calculate it.
116                self.on_equals();
117            }
118            // There is a result present, so put that on the left.
119            self.move_result_to_left();
120            self.current_num_index = 1;
121        } else if self.current_num_index == 0 {
122            if self.numbers[0].is_empty() {
123                // Not ready yet. Left number needed.
124                // invalid action
125                return;
126            } else {
127                self.current_num_index = 1;
128            }
129        }
130        self.operation = Some(operator);
131    }
132
133    /// For instances when you continue working with the prior result.
134    fn move_result_to_left(&mut self) {
135        self.clear_current_entry_on_input = true;
136        self.numbers[0] = self.result.clone().expect("expected result");
137        self.numbers[1] = "".into();
138        self.operation = None;
139        self.current_num_index = 0; // Moved to left
140        self.result = None; // It's moved, so remove the result.
141    }
142
143    fn on_equals(&mut self) {
144        // Requires both numbers be present
145        if self.numbers[0].is_empty() || self.numbers[1].is_empty() {
146            // invalid action
147            return; // Just abort.
148        } else if self.result.is_some() {
149            // Repeat the operation using the prior result on the left.
150            self.numbers[0] = self.result.clone().unwrap();
151        }
152        self.current_num_index = 0;
153        let num1 = self.numbers[0].parse::<f64>();
154        let num2 = self.numbers[1].parse::<f64>();
155        // Display format error or display the result of the operation.
156        self.result = Some(match (num1, num2) {
157            (Ok(num1), Ok(num2)) => self.operation.unwrap().perform_op(num1, num2).to_string(),
158            (Err(err), _) => err.to_string(),
159            (_, Err(err)) => err.to_string(),
160        });
161    }
162
163    fn on_delete(&mut self) {
164        if self.result.is_some() {
165            // Delete does not do anything with the result. Invalid action.
166            return;
167        }
168        let mut num = self.get_current_number();
169        if !num.is_empty() {
170            num.remove(num.len() - 1);
171            self.set_current_number(num);
172        } // else, invalid action
173    }
174
175    fn negate(&mut self) {
176        // If there is a result, negate that after clearing and moving it to the first number
177        if self.result.is_some() {
178            self.move_result_to_left();
179        }
180        let mut num = self.get_current_number();
181        if num.is_empty() {
182            // invalid action
183            return;
184        }
185        if num.starts_with('-') {
186            num.remove(0);
187        } else {
188            num = format!("-{num}");
189        }
190        self.set_current_number(num);
191    }
192}
193
194fn num_row(nums: [&'static str; 3], row: i32) -> impl GridSequence<Calculator> {
195    let mut views: Vec<_> = vec![];
196    for (i, num) in nums.iter().enumerate() {
197        views.push(digit_button(num).grid_pos(i as i32, row));
198    }
199    views
200}
201
202const DISPLAY_FONT_SIZE: f32 = 30.;
203const GRID_GAP: Length = Length::const_px(2.);
204fn app_logic(data: &mut Calculator) -> impl WidgetView<Calculator> + use<> {
205    grid(
206        (
207            // Display
208            centered_flex_row((
209                FlexSpacer::Flex(0.1),
210                display_label(data.numbers[0].as_ref()),
211                data.operation
212                    .map(|operation| display_label(operation.as_str())),
213                display_label(data.numbers[1].as_ref()),
214                data.result.is_some().then(|| display_label("=")),
215                data.result
216                    .as_ref()
217                    .map(|result| display_label(result.as_ref())),
218                FlexSpacer::Flex(0.1),
219            ))
220            .grid_item(GridParams::new(0, 0, 4, 1)),
221            // Top row
222            expanded_button(
223                label("CE").color(if data.get_current_number().is_empty() {
224                    palette::css::MEDIUM_VIOLET_RED
225                } else {
226                    palette::css::WHITE
227                }),
228                Calculator::clear_entry,
229            )
230            .grid_pos(0, 1),
231            expanded_button(label("C"), Calculator::clear_all).grid_pos(1, 1),
232            expanded_button(label("DEL"), Calculator::on_delete).grid_pos(2, 1),
233            operator_button(MathOperator::Divide).grid_pos(3, 1),
234            num_row(["7", "8", "9"], 2),
235            operator_button(MathOperator::Multiply).grid_pos(3, 2),
236            num_row(["4", "5", "6"], 3),
237            operator_button(MathOperator::Subtract).grid_pos(3, 3),
238            num_row(["1", "2", "3"], 4),
239            operator_button(MathOperator::Add).grid_pos(3, 4),
240            // bottom row
241            expanded_button(label("±"), Calculator::negate).grid_pos(0, 5),
242            digit_button("0").grid_pos(1, 5),
243            expanded_button(label("."), |data: &mut Calculator| {
244                data.on_entered_digit(".");
245            })
246            .grid_pos(2, 5),
247            expanded_button(label("="), Calculator::on_equals).grid_pos(3, 5),
248        ),
249        4,
250        6,
251    )
252    .spacing(GRID_GAP)
253}
254
255/// Creates a horizontal centered flex row designed for the display portion of the calculator.
256pub fn centered_flex_row<State, Seq: FlexSequence<State>>(sequence: Seq) -> Flex<Seq, State> {
257    flex_row(sequence)
258        .cross_axis_alignment(CrossAxisAlignment::Center)
259        .main_axis_alignment(MainAxisAlignment::Start)
260        .gap(5.px())
261}
262
263/// Returns a label intended to be used in the calculator's top display.
264/// The default text size is out of proportion for this use case.
265fn display_label(text: &str) -> impl WidgetView<Calculator> + use<> {
266    label(text).text_size(DISPLAY_FONT_SIZE)
267}
268
269/// Returns a button contained in an expanded box. Useful for the buttons so that
270/// they take up all available space in flex containers.
271fn expanded_button(
272    content: impl WidgetView<Calculator>,
273    callback: impl Fn(&mut Calculator) + Send + Sync + 'static,
274) -> impl WidgetView<Calculator> {
275    const BLUE: Color = Color::from_rgb8(0x00, 0x8d, 0xdd);
276
277    sized_box(
278        button(content, callback)
279            .background_color(BLUE)
280            .corner_radius(10.)
281            .border_color(Color::TRANSPARENT)
282            .hovered_border_color(Color::WHITE),
283    )
284    .expand()
285}
286
287/// Returns an expanded button that triggers the calculator's operator handler,
288/// `on_entered_operator()`.
289fn operator_button(math_operator: MathOperator) -> impl WidgetView<Calculator> {
290    expanded_button(
291        label(math_operator.as_str()),
292        move |data: &mut Calculator| {
293            data.on_entered_operator(math_operator);
294        },
295    )
296}
297
298/// A button which adds `digit` to the current input when pressed
299fn digit_button(digit: &'static str) -> impl WidgetView<Calculator> {
300    const GRAY: Color = Color::from_rgb8(0x3a, 0x3a, 0x3a);
301
302    sized_box(
303        text_button(digit, |data: &mut Calculator| {
304            data.on_entered_digit(digit);
305        })
306        .background_color(GRAY)
307        .corner_radius(10.)
308        .border_color(Color::TRANSPARENT),
309    )
310    .expand()
311}
312
313fn run(event_loop: EventLoopBuilder) -> Result<(), EventLoopError> {
314    let data = Calculator {
315        current_num_index: 0,
316        clear_current_entry_on_input: false,
317        numbers: ["".into(), "".into()],
318        result: None,
319        operation: None,
320    };
321
322    let min_window_size = LogicalSize::new(200., 200.);
323    let window_size = LogicalSize::new(400., 500.);
324    let window_options = WindowOptions::new("Calculator").with_min_inner_size(min_window_size);
325    // On iOS, winit has unsensible handling of `inner_size`
326    // See https://github.com/rust-windowing/winit/issues/2308 for more details
327    #[cfg(not(target_os = "ios"))]
328    let window_options = window_options.with_initial_inner_size(window_size);
329    let app = Xilem::new_simple(data, app_logic, window_options);
330    app.run_in(event_loop)?;
331    Ok(())
332}
333
334// Boilerplate code: Identical across all applications which support Android
335
336#[expect(clippy::allow_attributes, reason = "No way to specify the condition")]
337#[allow(dead_code, reason = "False positive: needed in not-_android version")]
338// This is treated as dead code by the Android version of the example, but is actually live
339// This hackery is required because Cargo doesn't care to support this use case, of one
340// example which works across Android and desktop
341fn main() -> Result<(), EventLoopError> {
342    run(EventLoop::with_user_event())
343}
344#[cfg(target_os = "android")]
345// Safety: We are following `android_activity`'s docs here
346#[expect(
347    unsafe_code,
348    reason = "We believe that there are no other declarations using this name in the compiled objects here"
349)]
350#[unsafe(no_mangle)]
351fn android_main(app: winit::platform::android::activity::AndroidApp) {
352    use winit::platform::android::EventLoopBuilderExtAndroid;
353
354    let mut event_loop = EventLoop::with_user_event();
355    event_loop.with_android_app(app);
356
357    run(event_loop).expect("Can create app");
358}