Refactor the example into a calculator, and let leaves be sized
The gallery showed off styles; a calculator exercises the same surface while being a thing you can actually use. Its keypad is data, walked to build the tree, and the state it shows — a pending operator, an error — has no widget of its own: it just changes what gets described next frame. Buttons took the width of their own labels, which makes a keypad look ragged and, at one character per key, tiny. cosmic_size sets the size of the next leaf and then resets, so the keys share one size without the container having to know anything about them. Text honours it too. The arithmetic is pure Nim, so tests/tcalculator.nim drives onPress directly and reads the display back: chaining, division by zero and the error latch, decimals, sign, percent. No window, no call into the cdylib. Adds an export, so this is v0.3.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dee3349 parent: fcee0b9 modified
.gitignore +4 -2 | @@ -1,6 +1,8 @@ | ||
| 1 | 1 | target/ |
| 2 | -examples/gallery | |
| 3 | -nim/app | |
| 2 | +examples/* | |
| 3 | +!examples/*.nim | |
| 4 | +!examples/nim.cfg | |
| 5 | +tests/tcalculator | |
| 4 | 6 | nimcache/ |
| 5 | 7 | *.so |
| 6 | 8 | run.log |
| @@ -1,6 +1,8 @@ | |||
| 1 | target/ | 1 | target/ |
| 2 | -examples/gallery | 2 | +examples/* |
| 3 | -nim/app | 3 | +!examples/*.nim |
| 4 | +!examples/nim.cfg | ||
| 5 | +tests/tcalculator | ||
| 4 | nimcache/ | 6 | nimcache/ |
| 5 | *.so | 7 | *.so |
| 6 | run.log | 8 | run.log |
modified
README.md +4 -2 | @@ -10,7 +10,8 @@ crate and exports a handful of functions. | ||
| 10 | 10 | ``` |
| 11 | 11 | cosmic_ffi/ Rust cdylib -> libcosmic_ffi.so + cosmic_ffi.h |
| 12 | 12 | nim/ the Nim binding (cosmicnim.nim), and where the .so lands |
| 13 | -examples/ gallery.nim, a demo of what the builder can express | |
| 13 | +examples/ calculator.nim, a worked example | |
| 14 | +tests/ headless tests of the example's logic | |
| 14 | 15 | cosmicnim.nimble package metadata and the fetchLib task |
| 15 | 16 | justfile build / run / fetch tasks |
| 16 | 17 | ``` |
| @@ -64,7 +65,8 @@ The demo needs `nim/libcosmic_ffi.so`. Take it from a release: | ||
| 64 | 65 | |
| 65 | 66 | ```bash |
| 66 | 67 | just fetch # or: nimble fetchLib |
| 67 | -just run | |
| 68 | +just run # the calculator | |
| 69 | +just test # its arithmetic, headless — no window, no cdylib call | |
| 68 | 70 | ``` |
| 69 | 71 | |
| 70 | 72 | The package version *is* the ABI version: cosmicnim 0.2.0 fetches the |
| @@ -10,7 +10,8 @@ crate and exports a handful of functions. | |||
| 10 | ``` | 10 | ``` |
| 11 | cosmic_ffi/ Rust cdylib -> libcosmic_ffi.so + cosmic_ffi.h | 11 | cosmic_ffi/ Rust cdylib -> libcosmic_ffi.so + cosmic_ffi.h |
| 12 | nim/ the Nim binding (cosmicnim.nim), and where the .so lands | 12 | nim/ the Nim binding (cosmicnim.nim), and where the .so lands |
| 13 | -examples/ gallery.nim, a demo of what the builder can express | 13 | +examples/ calculator.nim, a worked example |
| 14 | +tests/ headless tests of the example's logic | ||
| 14 | cosmicnim.nimble package metadata and the fetchLib task | 15 | cosmicnim.nimble package metadata and the fetchLib task |
| 15 | justfile build / run / fetch tasks | 16 | justfile build / run / fetch tasks |
| 16 | ``` | 17 | ``` |
| @@ -64,7 +65,8 @@ The demo needs `nim/libcosmic_ffi.so`. Take it from a release: | |||
| 64 | 65 | ||
| 65 | ```bash | 66 | ```bash |
| 66 | just fetch # or: nimble fetchLib | 67 | just fetch # or: nimble fetchLib |
| 67 | -just run | 68 | +just run # the calculator |
| 69 | +just test # its arithmetic, headless — no window, no cdylib call | ||
| 68 | ``` | 70 | ``` |
| 69 | 71 | ||
| 70 | The package version *is* the ABI version: cosmicnim 0.2.0 fetches the | 72 | The package version *is* the ABI version: cosmicnim 0.2.0 fetches the |
modified
cosmic_ffi/Cargo.lock +1 -1 | @@ -964,7 +964,7 @@ dependencies = [ | ||
| 964 | 964 | |
| 965 | 965 | [[package]] |
| 966 | 966 | name = "cosmic_ffi" |
| 967 | -version = "0.2.0" | |
| 967 | +version = "0.3.0" | |
| 968 | 968 | dependencies = [ |
| 969 | 969 | "libcosmic", |
| 970 | 970 | ] |
| @@ -964,7 +964,7 @@ dependencies = [ | |||
| 964 | 964 | ||
| 965 | [[package]] | 965 | [[package]] |
| 966 | name = "cosmic_ffi" | 966 | name = "cosmic_ffi" |
| 967 | -version = "0.2.0" | 967 | +version = "0.3.0" |
| 968 | dependencies = [ | 968 | dependencies = [ |
| 969 | "libcosmic", | 969 | "libcosmic", |
| 970 | ] | 970 | ] |
modified
cosmic_ffi/Cargo.toml +1 -1 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | [package] |
| 2 | 2 | name = "cosmic_ffi" |
| 3 | -version = "0.2.0" | |
| 3 | +version = "0.3.0" | |
| 4 | 4 | edition = "2021" |
| 5 | 5 | |
| 6 | 6 | [lib] |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "cosmic_ffi" | 2 | name = "cosmic_ffi" |
| 3 | -version = "0.2.0" | 3 | +version = "0.3.0" |
| 4 | edition = "2021" | 4 | edition = "2021" |
| 5 | 5 | ||
| 6 | [lib] | 6 | [lib] |
modified
cosmic_ffi/cosmic_ffi.h +4 -0 | @@ -63,6 +63,10 @@ void cosmic_button(CosmicBuilder *b, int32_t style, const char *label, | ||
| 63 | 63 | |
| 64 | 64 | void cosmic_space(CosmicBuilder *b, float w, float h); |
| 65 | 65 | |
| 66 | +/* Size the NEXT leaf, in pixels; a negative dimension leaves that axis at its | |
| 67 | + natural size. Applies to one leaf only, then resets. */ | |
| 68 | +void cosmic_size(CosmicBuilder *b, float w, float h); | |
| 69 | + | |
| 66 | 70 | /* --- the active theme's spacing scale, for laying out in COSMIC's rhythm --- */ |
| 67 | 71 | #define COSMIC_SPACE_NONE 0 |
| 68 | 72 | #define COSMIC_SPACE_XXXS 1 |
| @@ -63,6 +63,10 @@ void cosmic_button(CosmicBuilder *b, int32_t style, const char *label, | |||
| 63 | 63 | ||
| 64 | void cosmic_space(CosmicBuilder *b, float w, float h); | 64 | void cosmic_space(CosmicBuilder *b, float w, float h); |
| 65 | 65 | ||
| 66 | +/* Size the NEXT leaf, in pixels; a negative dimension leaves that axis at its | ||
| 67 | + natural size. Applies to one leaf only, then resets. */ | ||
| 68 | +void cosmic_size(CosmicBuilder *b, float w, float h); | ||
| 69 | + | ||
| 66 | /* --- the active theme's spacing scale, for laying out in COSMIC's rhythm --- */ | 70 | /* --- the active theme's spacing scale, for laying out in COSMIC's rhythm --- */ |
| 67 | #define COSMIC_SPACE_NONE 0 | 71 | #define COSMIC_SPACE_NONE 0 |
| 68 | #define COSMIC_SPACE_XXXS 1 | 72 | #define COSMIC_SPACE_XXXS 1 |
modified
cosmic_ffi/src/lib.rs +47 -11 | @@ -121,12 +121,16 @@ impl Frame { | ||
| 121 | 121 | pub struct Builder { |
| 122 | 122 | /// Always non-empty: `stack[0]` is the implicit root. |
| 123 | 123 | stack: Vec<Frame>, |
| 124 | + /// Set by `cosmic_size` and consumed by the next leaf. Negative means | |
| 125 | + /// "natural size", which is the default for both dimensions. | |
| 126 | + next_size: (f32, f32), | |
| 124 | 127 | } |
| 125 | 128 | |
| 126 | 129 | impl Builder { |
| 127 | 130 | fn new() -> Self { |
| 128 | 131 | Builder { |
| 129 | 132 | stack: vec![Frame::new(Kind::Container)], |
| 133 | + next_size: (-1.0, -1.0), | |
| 130 | 134 | } |
| 131 | 135 | } |
| 132 | 136 | |
| @@ -148,6 +152,15 @@ impl Builder { | ||
| 148 | 152 | } |
| 149 | 153 | } |
| 150 | 154 | |
| 155 | + /// Take the pending size, resetting it so it applies to one leaf only. | |
| 156 | + fn take_size(&mut self) -> (Option<Length>, Option<Length>) { | |
| 157 | + let (w, h) = std::mem::replace(&mut self.next_size, (-1.0, -1.0)); | |
| 158 | + ( | |
| 159 | + (w >= 0.0).then_some(Length::Fixed(w)), | |
| 160 | + (h >= 0.0).then_some(Length::Fixed(h)), | |
| 161 | + ) | |
| 162 | + } | |
| 163 | + | |
| 151 | 164 | fn leaf(&mut self, element: Element) { |
| 152 | 165 | self.top().children.push(element); |
| 153 | 166 | } |
| @@ -220,6 +233,13 @@ pub unsafe extern "C" fn cosmic_fill(b: *mut Builder) { | ||
| 220 | 233 | builder!(b).top().fill = true; |
| 221 | 234 | } |
| 222 | 235 | |
| 236 | +/// Size the next leaf, in pixels. A negative dimension leaves that axis at | |
| 237 | +/// its natural size. Applies to one leaf only, then resets. | |
| 238 | +#[no_mangle] | |
| 239 | +pub unsafe extern "C" fn cosmic_size(b: *mut Builder, w: f32, h: f32) { | |
| 240 | + builder!(b).next_size = (w, h); | |
| 241 | +} | |
| 242 | + | |
| 223 | 243 | /// A blank gap of `w` by `h` pixels. |
| 224 | 244 | #[no_mangle] |
| 225 | 245 | pub unsafe extern "C" fn cosmic_space(b: *mut Builder, w: f32, h: f32) { |
| @@ -246,16 +266,24 @@ pub const TEXT_MONOTEXT: i32 = 7; | ||
| 246 | 266 | pub unsafe extern "C" fn cosmic_text(b: *mut Builder, style: i32, text: *const c_char) { |
| 247 | 267 | let b = builder!(b); |
| 248 | 268 | let s = str_or(text, ""); |
| 249 | - b.leaf(match style { | |
| 250 | - TEXT_TITLE1 => widget::text::title1(s).into(), | |
| 251 | - TEXT_TITLE2 => widget::text::title2(s).into(), | |
| 252 | - TEXT_TITLE3 => widget::text::title3(s).into(), | |
| 253 | - TEXT_TITLE4 => widget::text::title4(s).into(), | |
| 254 | - TEXT_HEADING => widget::text::heading(s).into(), | |
| 255 | - TEXT_CAPTION => widget::text::caption(s).into(), | |
| 256 | - TEXT_MONOTEXT => widget::text::monotext(s).into(), | |
| 257 | - _ => widget::text::body(s).into(), | |
| 258 | - }) | |
| 269 | + let (w, h) = b.take_size(); | |
| 270 | + let mut t = match style { | |
| 271 | + TEXT_TITLE1 => widget::text::title1(s), | |
| 272 | + TEXT_TITLE2 => widget::text::title2(s), | |
| 273 | + TEXT_TITLE3 => widget::text::title3(s), | |
| 274 | + TEXT_TITLE4 => widget::text::title4(s), | |
| 275 | + TEXT_HEADING => widget::text::heading(s), | |
| 276 | + TEXT_CAPTION => widget::text::caption(s), | |
| 277 | + TEXT_MONOTEXT => widget::text::monotext(s), | |
| 278 | + _ => widget::text::body(s), | |
| 279 | + }; | |
| 280 | + if let Some(w) = w { | |
| 281 | + t = t.width(w); | |
| 282 | + } | |
| 283 | + if let Some(h) = h { | |
| 284 | + t = t.height(h); | |
| 285 | + } | |
| 286 | + b.leaf(t.into()) | |
| 259 | 287 | } |
| 260 | 288 | |
| 261 | 289 | /// Button styles for [`cosmic_button`]. |
| @@ -271,11 +299,19 @@ pub const BUTTON_LINK: i32 = 4; | ||
| 271 | 299 | pub unsafe extern "C" fn cosmic_button(b: *mut Builder, style: i32, label: *const c_char, id: i32) { |
| 272 | 300 | let b = builder!(b); |
| 273 | 301 | let s = str_or(label, ""); |
| 302 | + let (bw, bh) = b.take_size(); | |
| 274 | 303 | // Each constructor returns a differently-typed `button::Builder`, so the |
| 275 | 304 | // arms cannot unify: finish each one into an Element where it is built. |
| 276 | 305 | macro_rules! finish { |
| 277 | 306 | ($button:expr) => {{ |
| 278 | - let button = $button; | |
| 307 | + #[allow(unused_mut)] | |
| 308 | + let mut button = $button; | |
| 309 | + if let Some(bw) = bw { | |
| 310 | + button = button.width(bw); | |
| 311 | + } | |
| 312 | + if let Some(bh) = bh { | |
| 313 | + button = button.height(bh); | |
| 314 | + } | |
| 279 | 315 | if id < 0 { |
| 280 | 316 | button.into() |
| 281 | 317 | } else { |
| @@ -121,12 +121,16 @@ impl Frame { | |||
| 121 | pub struct Builder { | 121 | pub struct Builder { |
| 122 | /// Always non-empty: `stack[0]` is the implicit root. | 122 | /// Always non-empty: `stack[0]` is the implicit root. |
| 123 | stack: Vec<Frame>, | 123 | stack: Vec<Frame>, |
| 124 | + /// Set by `cosmic_size` and consumed by the next leaf. Negative means | ||
| 125 | + /// "natural size", which is the default for both dimensions. | ||
| 126 | + next_size: (f32, f32), | ||
| 124 | } | 127 | } |
| 125 | 128 | ||
| 126 | impl Builder { | 129 | impl Builder { |
| 127 | fn new() -> Self { | 130 | fn new() -> Self { |
| 128 | Builder { | 131 | Builder { |
| 129 | stack: vec![Frame::new(Kind::Container)], | 132 | stack: vec![Frame::new(Kind::Container)], |
| 133 | + next_size: (-1.0, -1.0), | ||
| 130 | } | 134 | } |
| 131 | } | 135 | } |
| 132 | 136 | ||
| @@ -148,6 +152,15 @@ impl Builder { | |||
| 148 | } | 152 | } |
| 149 | } | 153 | } |
| 150 | 154 | ||
| 155 | + /// Take the pending size, resetting it so it applies to one leaf only. | ||
| 156 | + fn take_size(&mut self) -> (Option<Length>, Option<Length>) { | ||
| 157 | + let (w, h) = std::mem::replace(&mut self.next_size, (-1.0, -1.0)); | ||
| 158 | + ( | ||
| 159 | + (w >= 0.0).then_some(Length::Fixed(w)), | ||
| 160 | + (h >= 0.0).then_some(Length::Fixed(h)), | ||
| 161 | + ) | ||
| 162 | + } | ||
| 163 | + | ||
| 151 | fn leaf(&mut self, element: Element) { | 164 | fn leaf(&mut self, element: Element) { |
| 152 | self.top().children.push(element); | 165 | self.top().children.push(element); |
| 153 | } | 166 | } |
| @@ -220,6 +233,13 @@ pub unsafe extern "C" fn cosmic_fill(b: *mut Builder) { | |||
| 220 | builder!(b).top().fill = true; | 233 | builder!(b).top().fill = true; |
| 221 | } | 234 | } |
| 222 | 235 | ||
| 236 | +/// Size the next leaf, in pixels. A negative dimension leaves that axis at | ||
| 237 | +/// its natural size. Applies to one leaf only, then resets. | ||
| 238 | +#[no_mangle] | ||
| 239 | +pub unsafe extern "C" fn cosmic_size(b: *mut Builder, w: f32, h: f32) { | ||
| 240 | + builder!(b).next_size = (w, h); | ||
| 241 | +} | ||
| 242 | + | ||
| 223 | /// A blank gap of `w` by `h` pixels. | 243 | /// A blank gap of `w` by `h` pixels. |
| 224 | #[no_mangle] | 244 | #[no_mangle] |
| 225 | pub unsafe extern "C" fn cosmic_space(b: *mut Builder, w: f32, h: f32) { | 245 | pub unsafe extern "C" fn cosmic_space(b: *mut Builder, w: f32, h: f32) { |
| @@ -246,16 +266,24 @@ pub const TEXT_MONOTEXT: i32 = 7; | |||
| 246 | pub unsafe extern "C" fn cosmic_text(b: *mut Builder, style: i32, text: *const c_char) { | 266 | pub unsafe extern "C" fn cosmic_text(b: *mut Builder, style: i32, text: *const c_char) { |
| 247 | let b = builder!(b); | 267 | let b = builder!(b); |
| 248 | let s = str_or(text, ""); | 268 | let s = str_or(text, ""); |
| 249 | - b.leaf(match style { | 269 | + let (w, h) = b.take_size(); |
| 250 | - TEXT_TITLE1 => widget::text::title1(s).into(), | 270 | + let mut t = match style { |
| 251 | - TEXT_TITLE2 => widget::text::title2(s).into(), | 271 | + TEXT_TITLE1 => widget::text::title1(s), |
| 252 | - TEXT_TITLE3 => widget::text::title3(s).into(), | 272 | + TEXT_TITLE2 => widget::text::title2(s), |
| 253 | - TEXT_TITLE4 => widget::text::title4(s).into(), | 273 | + TEXT_TITLE3 => widget::text::title3(s), |
| 254 | - TEXT_HEADING => widget::text::heading(s).into(), | 274 | + TEXT_TITLE4 => widget::text::title4(s), |
| 255 | - TEXT_CAPTION => widget::text::caption(s).into(), | 275 | + TEXT_HEADING => widget::text::heading(s), |
| 256 | - TEXT_MONOTEXT => widget::text::monotext(s).into(), | 276 | + TEXT_CAPTION => widget::text::caption(s), |
| 257 | - _ => widget::text::body(s).into(), | 277 | + TEXT_MONOTEXT => widget::text::monotext(s), |
| 258 | - }) | 278 | + _ => widget::text::body(s), |
| 279 | + }; | ||
| 280 | + if let Some(w) = w { | ||
| 281 | + t = t.width(w); | ||
| 282 | + } | ||
| 283 | + if let Some(h) = h { | ||
| 284 | + t = t.height(h); | ||
| 285 | + } | ||
| 286 | + b.leaf(t.into()) | ||
| 259 | } | 287 | } |
| 260 | 288 | ||
| 261 | /// Button styles for [`cosmic_button`]. | 289 | /// Button styles for [`cosmic_button`]. |
| @@ -271,11 +299,19 @@ pub const BUTTON_LINK: i32 = 4; | |||
| 271 | pub unsafe extern "C" fn cosmic_button(b: *mut Builder, style: i32, label: *const c_char, id: i32) { | 299 | pub unsafe extern "C" fn cosmic_button(b: *mut Builder, style: i32, label: *const c_char, id: i32) { |
| 272 | let b = builder!(b); | 300 | let b = builder!(b); |
| 273 | let s = str_or(label, ""); | 301 | let s = str_or(label, ""); |
| 302 | + let (bw, bh) = b.take_size(); | ||
| 274 | // Each constructor returns a differently-typed `button::Builder`, so the | 303 | // Each constructor returns a differently-typed `button::Builder`, so the |
| 275 | // arms cannot unify: finish each one into an Element where it is built. | 304 | // arms cannot unify: finish each one into an Element where it is built. |
| 276 | macro_rules! finish { | 305 | macro_rules! finish { |
| 277 | ($button:expr) => {{ | 306 | ($button:expr) => {{ |
| 278 | - let button = $button; | 307 | + #[allow(unused_mut)] |
| 308 | + let mut button = $button; | ||
| 309 | + if let Some(bw) = bw { | ||
| 310 | + button = button.width(bw); | ||
| 311 | + } | ||
| 312 | + if let Some(bh) = bh { | ||
| 313 | + button = button.height(bh); | ||
| 314 | + } | ||
| 279 | if id < 0 { | 315 | if id < 0 { |
| 280 | button.into() | 316 | button.into() |
| 281 | } else { | 317 | } else { |
modified
cosmicnim.nimble +1 -1 | @@ -1,4 +1,4 @@ | ||
| 1 | -version = "0.2.0" | |
| 1 | +version = "0.3.0" | |
| 2 | 2 | author = "nandi" |
| 3 | 3 | description = "libcosmic behind a C ABI, driven from Nim" |
| 4 | 4 | license = "MIT" |
| @@ -1,4 +1,4 @@ | |||
| 1 | -version = "0.2.0" | 1 | +version = "0.3.0" |
| 2 | author = "nandi" | 2 | author = "nandi" |
| 3 | description = "libcosmic behind a C ABI, driven from Nim" | 3 | description = "libcosmic behind a C ABI, driven from Nim" |
| 4 | license = "MIT" | 4 | license = "MIT" |
added
examples/calculator.nim +198 -0 | new file mode 100644 | ||
| @@ -0,0 +1,198 @@ | ||
| 1 | +## A calculator whose arithmetic and layout both live in Nim. | |
| 2 | +## | |
| 3 | +## The keypad is described as data and walked to build the tree, so the rows | |
| 4 | +## in the source are the rows on screen. State that has no button — a pending | |
| 5 | +## operator, an error — simply changes what gets described next frame. | |
| 6 | + | |
| 7 | +import std/[math, strformat, strutils] | |
| 8 | +import cosmicnim | |
| 9 | + | |
| 10 | +type | |
| 11 | + Op = enum | |
| 12 | + opNone = "", opAdd = "+", opSub = "−", opMul = "×", opDiv = "÷" | |
| 13 | + | |
| 14 | + Key = object | |
| 15 | + label: string | |
| 16 | + id: int32 | |
| 17 | + style: ButtonStyle | |
| 18 | + | |
| 19 | + Calc* = object | |
| 20 | + entry: string ## digits being typed; empty means "showing the accumulator" | |
| 21 | + acc: float | |
| 22 | + pending: Op | |
| 23 | + startNew: bool ## next digit starts a fresh entry | |
| 24 | + error: bool | |
| 25 | + | |
| 26 | +const | |
| 27 | + # A keypad wants a grid, and the ABI sizes leaves in pixels, so the keys | |
| 28 | + # are given one size rather than each taking the width of its own label. | |
| 29 | + KeyW = 68.0 | |
| 30 | + KeyH = 56.0 | |
| 31 | + | |
| 32 | + # 0..9 are the digit keys, so their id is their value. | |
| 33 | + IdDot* = 10'i32 | |
| 34 | + IdAdd* = 11'i32 | |
| 35 | + IdSub* = 12'i32 | |
| 36 | + IdMul* = 13'i32 | |
| 37 | + IdDiv* = 14'i32 | |
| 38 | + IdEquals* = 15'i32 | |
| 39 | + IdClear* = 16'i32 | |
| 40 | + IdSign* = 17'i32 | |
| 41 | + IdPercent* = 18'i32 | |
| 42 | + | |
| 43 | + Keypad: array[5, array[4, Key]] = [ | |
| 44 | + [Key(label: "C", id: IdClear, style: ButtonDestructive), | |
| 45 | + Key(label: "±", id: IdSign, style: ButtonText), | |
| 46 | + Key(label: "%", id: IdPercent, style: ButtonText), | |
| 47 | + Key(label: "÷", id: IdDiv, style: ButtonStandard)], | |
| 48 | + [Key(label: "7", id: 7, style: ButtonStandard), | |
| 49 | + Key(label: "8", id: 8, style: ButtonStandard), | |
| 50 | + Key(label: "9", id: 9, style: ButtonStandard), | |
| 51 | + Key(label: "×", id: IdMul, style: ButtonStandard)], | |
| 52 | + [Key(label: "4", id: 4, style: ButtonStandard), | |
| 53 | + Key(label: "5", id: 5, style: ButtonStandard), | |
| 54 | + Key(label: "6", id: 6, style: ButtonStandard), | |
| 55 | + Key(label: "−", id: IdSub, style: ButtonStandard)], | |
| 56 | + [Key(label: "1", id: 1, style: ButtonStandard), | |
| 57 | + Key(label: "2", id: 2, style: ButtonStandard), | |
| 58 | + Key(label: "3", id: 3, style: ButtonStandard), | |
| 59 | + Key(label: "+", id: IdAdd, style: ButtonStandard)], | |
| 60 | + [Key(label: "0", id: 0, style: ButtonStandard), | |
| 61 | + Key(label: ".", id: IdDot, style: ButtonStandard), | |
| 62 | + Key(label: "=", id: IdEquals, style: ButtonSuggested), | |
| 63 | + Key(label: "", id: -1, style: ButtonText)], # id -1: an inert spacer | |
| 64 | + ] | |
| 65 | + | |
| 66 | +proc newCalc*(): Calc = | |
| 67 | + ## A calculator showing zero, waiting for its first digit. | |
| 68 | + Calc(startNew: true) | |
| 69 | + | |
| 70 | +proc format(v: float): string = | |
| 71 | + ## Integers read as integers; everything else loses its trailing zeros. | |
| 72 | + if v != v: | |
| 73 | + result = "NaN" | |
| 74 | + elif v in [Inf, NegInf]: | |
| 75 | + result = "∞" | |
| 76 | + elif v == trunc(v) and abs(v) < 1e15: | |
| 77 | + result = $int64(v) | |
| 78 | + else: | |
| 79 | + result = formatFloat(v, ffDefault, 12) | |
| 80 | + result.trimZeros() | |
| 81 | + | |
| 82 | +proc current(c: Calc): float = | |
| 83 | + ## What the display is showing, as a number. | |
| 84 | + if c.entry.len > 0: parseFloat(c.entry) else: c.acc | |
| 85 | + | |
| 86 | +proc display*(c: Calc): string = | |
| 87 | + if c.error: "Error" | |
| 88 | + elif c.entry.len > 0: c.entry | |
| 89 | + else: format(c.acc) | |
| 90 | + | |
| 91 | +proc resolve(c: var Calc) = | |
| 92 | + ## Fold the entry into the accumulator using the pending operator. | |
| 93 | + let rhs = c.current | |
| 94 | + case c.pending | |
| 95 | + of opNone: c.acc = rhs | |
| 96 | + of opAdd: c.acc = c.acc + rhs | |
| 97 | + of opSub: c.acc = c.acc - rhs | |
| 98 | + of opMul: c.acc = c.acc * rhs | |
| 99 | + of opDiv: | |
| 100 | + if rhs == 0.0: c.error = true | |
| 101 | + else: c.acc = c.acc / rhs | |
| 102 | + c.entry = "" | |
| 103 | + | |
| 104 | +proc digit(c: var Calc; d: int32) = | |
| 105 | + if c.startNew: | |
| 106 | + c.entry = "" | |
| 107 | + c.startNew = false | |
| 108 | + if c.entry == "0": c.entry = "" # no leading zeros | |
| 109 | + c.entry.add $d | |
| 110 | + | |
| 111 | +proc onPress*(ctx: pointer; id: int32) {.cdecl.} = | |
| 112 | + let c = cast[ptr Calc](ctx) | |
| 113 | + | |
| 114 | + # Clear is the only key that works once the display says Error. | |
| 115 | + if id == IdClear: | |
| 116 | + c[] = newCalc() | |
| 117 | + return | |
| 118 | + if c.error: | |
| 119 | + return | |
| 120 | + | |
| 121 | + case id | |
| 122 | + of 0'i32 .. 9'i32: | |
| 123 | + c[].digit(id) | |
| 124 | + of IdDot: | |
| 125 | + if c.startNew: | |
| 126 | + c.entry = "0" | |
| 127 | + c.startNew = false | |
| 128 | + if c.entry.len == 0: c.entry = "0" | |
| 129 | + if '.' notin c.entry: c.entry.add '.' | |
| 130 | + of IdAdd, IdSub, IdMul, IdDiv: | |
| 131 | + c[].resolve() | |
| 132 | + if not c.error: | |
| 133 | + c.pending = case id | |
| 134 | + of IdAdd: opAdd | |
| 135 | + of IdSub: opSub | |
| 136 | + of IdMul: opMul | |
| 137 | + else: opDiv | |
| 138 | + c.startNew = true | |
| 139 | + of IdEquals: | |
| 140 | + c[].resolve() | |
| 141 | + c.pending = opNone | |
| 142 | + c.startNew = true | |
| 143 | + of IdSign: | |
| 144 | + if c.entry.len > 0: | |
| 145 | + if c.entry.startsWith('-'): c.entry = c.entry[1 .. ^1] | |
| 146 | + else: c.entry = "-" & c.entry | |
| 147 | + else: | |
| 148 | + c.acc = -c.acc | |
| 149 | + of IdPercent: | |
| 150 | + if c.entry.len > 0: c.entry = format(parseFloat(c.entry) / 100.0) | |
| 151 | + else: c.acc = c.acc / 100.0 | |
| 152 | + else: | |
| 153 | + discard | |
| 154 | + | |
| 155 | +proc onView(ctx: pointer; b: Builder) {.cdecl.} = | |
| 156 | + let c = cast[ptr Calc](ctx) | |
| 157 | + | |
| 158 | + b.container: | |
| 159 | + b.fill() | |
| 160 | + b.alignCenter() | |
| 161 | + b.spacing(space(SpaceM)) | |
| 162 | + | |
| 163 | + # The pending operation only exists as a line when there is one. | |
| 164 | + if c.pending != opNone and not c.error: | |
| 165 | + b.text(&"{format(c.acc)} {c.pending}", TextCaption) | |
| 166 | + | |
| 167 | + b.text(c[].display, TextTitle1) | |
| 168 | + | |
| 169 | + b.column: | |
| 170 | + b.spacing(space(SpaceXs)) | |
| 171 | + for keyRow in Keypad: | |
| 172 | + b.row: | |
| 173 | + b.spacing(space(SpaceXs)) | |
| 174 | + b.alignCenter() | |
| 175 | + for key in keyRow: | |
| 176 | + b.size(KeyW, KeyH) | |
| 177 | + if key.label.len > 0: | |
| 178 | + b.button(key.label, key.id, key.style) | |
| 179 | + else: | |
| 180 | + b.space(KeyW, KeyH) # hold the column open | |
| 181 | + | |
| 182 | +proc main() = | |
| 183 | + var calc = newCalc() | |
| 184 | + var config = CosmicConfig( | |
| 185 | + title: "Nim ❤ COSMIC", | |
| 186 | + onView: onView, | |
| 187 | + onPress: onPress, | |
| 188 | + ctx: addr calc, | |
| 189 | + width: 380, | |
| 190 | + height: 560, | |
| 191 | + ) | |
| 192 | + let rc = cosmicRun(addr config) | |
| 193 | + if rc != 0: | |
| 194 | + quit(&"cosmic_run failed: {rc}", 1) | |
| 195 | + echo &"final display: {calc.display}" | |
| 196 | + | |
| 197 | +when isMainModule: | |
| 198 | + main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,198 @@ | |||
| 1 | +## A calculator whose arithmetic and layout both live in Nim. | ||
| 2 | +## | ||
| 3 | +## The keypad is described as data and walked to build the tree, so the rows | ||
| 4 | +## in the source are the rows on screen. State that has no button — a pending | ||
| 5 | +## operator, an error — simply changes what gets described next frame. | ||
| 6 | + | ||
| 7 | +import std/[math, strformat, strutils] | ||
| 8 | +import cosmicnim | ||
| 9 | + | ||
| 10 | +type | ||
| 11 | + Op = enum | ||
| 12 | + opNone = "", opAdd = "+", opSub = "−", opMul = "×", opDiv = "÷" | ||
| 13 | + | ||
| 14 | + Key = object | ||
| 15 | + label: string | ||
| 16 | + id: int32 | ||
| 17 | + style: ButtonStyle | ||
| 18 | + | ||
| 19 | + Calc* = object | ||
| 20 | + entry: string ## digits being typed; empty means "showing the accumulator" | ||
| 21 | + acc: float | ||
| 22 | + pending: Op | ||
| 23 | + startNew: bool ## next digit starts a fresh entry | ||
| 24 | + error: bool | ||
| 25 | + | ||
| 26 | +const | ||
| 27 | + # A keypad wants a grid, and the ABI sizes leaves in pixels, so the keys | ||
| 28 | + # are given one size rather than each taking the width of its own label. | ||
| 29 | + KeyW = 68.0 | ||
| 30 | + KeyH = 56.0 | ||
| 31 | + | ||
| 32 | + # 0..9 are the digit keys, so their id is their value. | ||
| 33 | + IdDot* = 10'i32 | ||
| 34 | + IdAdd* = 11'i32 | ||
| 35 | + IdSub* = 12'i32 | ||
| 36 | + IdMul* = 13'i32 | ||
| 37 | + IdDiv* = 14'i32 | ||
| 38 | + IdEquals* = 15'i32 | ||
| 39 | + IdClear* = 16'i32 | ||
| 40 | + IdSign* = 17'i32 | ||
| 41 | + IdPercent* = 18'i32 | ||
| 42 | + | ||
| 43 | + Keypad: array[5, array[4, Key]] = [ | ||
| 44 | + [Key(label: "C", id: IdClear, style: ButtonDestructive), | ||
| 45 | + Key(label: "±", id: IdSign, style: ButtonText), | ||
| 46 | + Key(label: "%", id: IdPercent, style: ButtonText), | ||
| 47 | + Key(label: "÷", id: IdDiv, style: ButtonStandard)], | ||
| 48 | + [Key(label: "7", id: 7, style: ButtonStandard), | ||
| 49 | + Key(label: "8", id: 8, style: ButtonStandard), | ||
| 50 | + Key(label: "9", id: 9, style: ButtonStandard), | ||
| 51 | + Key(label: "×", id: IdMul, style: ButtonStandard)], | ||
| 52 | + [Key(label: "4", id: 4, style: ButtonStandard), | ||
| 53 | + Key(label: "5", id: 5, style: ButtonStandard), | ||
| 54 | + Key(label: "6", id: 6, style: ButtonStandard), | ||
| 55 | + Key(label: "−", id: IdSub, style: ButtonStandard)], | ||
| 56 | + [Key(label: "1", id: 1, style: ButtonStandard), | ||
| 57 | + Key(label: "2", id: 2, style: ButtonStandard), | ||
| 58 | + Key(label: "3", id: 3, style: ButtonStandard), | ||
| 59 | + Key(label: "+", id: IdAdd, style: ButtonStandard)], | ||
| 60 | + [Key(label: "0", id: 0, style: ButtonStandard), | ||
| 61 | + Key(label: ".", id: IdDot, style: ButtonStandard), | ||
| 62 | + Key(label: "=", id: IdEquals, style: ButtonSuggested), | ||
| 63 | + Key(label: "", id: -1, style: ButtonText)], # id -1: an inert spacer | ||
| 64 | + ] | ||
| 65 | + | ||
| 66 | +proc newCalc*(): Calc = | ||
| 67 | + ## A calculator showing zero, waiting for its first digit. | ||
| 68 | + Calc(startNew: true) | ||
| 69 | + | ||
| 70 | +proc format(v: float): string = | ||
| 71 | + ## Integers read as integers; everything else loses its trailing zeros. | ||
| 72 | + if v != v: | ||
| 73 | + result = "NaN" | ||
| 74 | + elif v in [Inf, NegInf]: | ||
| 75 | + result = "∞" | ||
| 76 | + elif v == trunc(v) and abs(v) < 1e15: | ||
| 77 | + result = $int64(v) | ||
| 78 | + else: | ||
| 79 | + result = formatFloat(v, ffDefault, 12) | ||
| 80 | + result.trimZeros() | ||
| 81 | + | ||
| 82 | +proc current(c: Calc): float = | ||
| 83 | + ## What the display is showing, as a number. | ||
| 84 | + if c.entry.len > 0: parseFloat(c.entry) else: c.acc | ||
| 85 | + | ||
| 86 | +proc display*(c: Calc): string = | ||
| 87 | + if c.error: "Error" | ||
| 88 | + elif c.entry.len > 0: c.entry | ||
| 89 | + else: format(c.acc) | ||
| 90 | + | ||
| 91 | +proc resolve(c: var Calc) = | ||
| 92 | + ## Fold the entry into the accumulator using the pending operator. | ||
| 93 | + let rhs = c.current | ||
| 94 | + case c.pending | ||
| 95 | + of opNone: c.acc = rhs | ||
| 96 | + of opAdd: c.acc = c.acc + rhs | ||
| 97 | + of opSub: c.acc = c.acc - rhs | ||
| 98 | + of opMul: c.acc = c.acc * rhs | ||
| 99 | + of opDiv: | ||
| 100 | + if rhs == 0.0: c.error = true | ||
| 101 | + else: c.acc = c.acc / rhs | ||
| 102 | + c.entry = "" | ||
| 103 | + | ||
| 104 | +proc digit(c: var Calc; d: int32) = | ||
| 105 | + if c.startNew: | ||
| 106 | + c.entry = "" | ||
| 107 | + c.startNew = false | ||
| 108 | + if c.entry == "0": c.entry = "" # no leading zeros | ||
| 109 | + c.entry.add $d | ||
| 110 | + | ||
| 111 | +proc onPress*(ctx: pointer; id: int32) {.cdecl.} = | ||
| 112 | + let c = cast[ptr Calc](ctx) | ||
| 113 | + | ||
| 114 | + # Clear is the only key that works once the display says Error. | ||
| 115 | + if id == IdClear: | ||
| 116 | + c[] = newCalc() | ||
| 117 | + return | ||
| 118 | + if c.error: | ||
| 119 | + return | ||
| 120 | + | ||
| 121 | + case id | ||
| 122 | + of 0'i32 .. 9'i32: | ||
| 123 | + c[].digit(id) | ||
| 124 | + of IdDot: | ||
| 125 | + if c.startNew: | ||
| 126 | + c.entry = "0" | ||
| 127 | + c.startNew = false | ||
| 128 | + if c.entry.len == 0: c.entry = "0" | ||
| 129 | + if '.' notin c.entry: c.entry.add '.' | ||
| 130 | + of IdAdd, IdSub, IdMul, IdDiv: | ||
| 131 | + c[].resolve() | ||
| 132 | + if not c.error: | ||
| 133 | + c.pending = case id | ||
| 134 | + of IdAdd: opAdd | ||
| 135 | + of IdSub: opSub | ||
| 136 | + of IdMul: opMul | ||
| 137 | + else: opDiv | ||
| 138 | + c.startNew = true | ||
| 139 | + of IdEquals: | ||
| 140 | + c[].resolve() | ||
| 141 | + c.pending = opNone | ||
| 142 | + c.startNew = true | ||
| 143 | + of IdSign: | ||
| 144 | + if c.entry.len > 0: | ||
| 145 | + if c.entry.startsWith('-'): c.entry = c.entry[1 .. ^1] | ||
| 146 | + else: c.entry = "-" & c.entry | ||
| 147 | + else: | ||
| 148 | + c.acc = -c.acc | ||
| 149 | + of IdPercent: | ||
| 150 | + if c.entry.len > 0: c.entry = format(parseFloat(c.entry) / 100.0) | ||
| 151 | + else: c.acc = c.acc / 100.0 | ||
| 152 | + else: | ||
| 153 | + discard | ||
| 154 | + | ||
| 155 | +proc onView(ctx: pointer; b: Builder) {.cdecl.} = | ||
| 156 | + let c = cast[ptr Calc](ctx) | ||
| 157 | + | ||
| 158 | + b.container: | ||
| 159 | + b.fill() | ||
| 160 | + b.alignCenter() | ||
| 161 | + b.spacing(space(SpaceM)) | ||
| 162 | + | ||
| 163 | + # The pending operation only exists as a line when there is one. | ||
| 164 | + if c.pending != opNone and not c.error: | ||
| 165 | + b.text(&"{format(c.acc)} {c.pending}", TextCaption) | ||
| 166 | + | ||
| 167 | + b.text(c[].display, TextTitle1) | ||
| 168 | + | ||
| 169 | + b.column: | ||
| 170 | + b.spacing(space(SpaceXs)) | ||
| 171 | + for keyRow in Keypad: | ||
| 172 | + b.row: | ||
| 173 | + b.spacing(space(SpaceXs)) | ||
| 174 | + b.alignCenter() | ||
| 175 | + for key in keyRow: | ||
| 176 | + b.size(KeyW, KeyH) | ||
| 177 | + if key.label.len > 0: | ||
| 178 | + b.button(key.label, key.id, key.style) | ||
| 179 | + else: | ||
| 180 | + b.space(KeyW, KeyH) # hold the column open | ||
| 181 | + | ||
| 182 | +proc main() = | ||
| 183 | + var calc = newCalc() | ||
| 184 | + var config = CosmicConfig( | ||
| 185 | + title: "Nim ❤ COSMIC", | ||
| 186 | + onView: onView, | ||
| 187 | + onPress: onPress, | ||
| 188 | + ctx: addr calc, | ||
| 189 | + width: 380, | ||
| 190 | + height: 560, | ||
| 191 | + ) | ||
| 192 | + let rc = cosmicRun(addr config) | ||
| 193 | + if rc != 0: | ||
| 194 | + quit(&"cosmic_run failed: {rc}", 1) | ||
| 195 | + echo &"final display: {calc.display}" | ||
| 196 | + | ||
| 197 | +when isMainModule: | ||
| 198 | + main() | ||
deleted
examples/gallery.nim +0 -178 | deleted file mode 100644 | ||
| @@ -1,178 +0,0 @@ | ||
| 1 | -## A small gallery of what the builder can express. | |
| 2 | -## | |
| 3 | -## Every frame the whole tree is rebuilt from Nim state, so switching pages | |
| 4 | -## is not a widget being shown or hidden — it is a different tree. The same | |
| 5 | -## goes for the disabled buttons and the history line: they are simply not | |
| 6 | -## described when the state does not call for them. | |
| 7 | - | |
| 8 | -import std/[strformat, strutils] | |
| 9 | -import cosmicnim | |
| 10 | - | |
| 11 | -type Page = enum | |
| 12 | - PageCounter = "Counter" | |
| 13 | - PageText = "Text" | |
| 14 | - PageButtons = "Buttons" | |
| 15 | - PageLayout = "Layout" | |
| 16 | - | |
| 17 | -const | |
| 18 | - # Nav takes one id per page; pages take ids from 10 up. | |
| 19 | - IdNavBase = 0'i32 | |
| 20 | - IdDec = 10'i32 | |
| 21 | - IdInc = 11'i32 | |
| 22 | - IdReset = 12'i32 | |
| 23 | - IdStepDown = 13'i32 | |
| 24 | - IdStepUp = 14'i32 | |
| 25 | - IdPokeStandard = 20'i32 | |
| 26 | - IdPokeSuggested = 21'i32 | |
| 27 | - IdPokeDestructive = 22'i32 | |
| 28 | - IdGapNarrower = 30'i32 | |
| 29 | - IdGapWider = 31'i32 | |
| 30 | - | |
| 31 | -type State = object | |
| 32 | - page: Page | |
| 33 | - value: int | |
| 34 | - step: int | |
| 35 | - history: seq[int] | |
| 36 | - poked: string | |
| 37 | - gap: int | |
| 38 | - | |
| 39 | -proc apply(s: var State; delta: int) = | |
| 40 | - s.history.add s.value | |
| 41 | - if s.history.len > 6: | |
| 42 | - s.history.delete(0) | |
| 43 | - s.value += delta | |
| 44 | - | |
| 45 | -proc onPress(ctx: pointer; id: int32) {.cdecl.} = | |
| 46 | - let s = cast[ptr State](ctx) | |
| 47 | - if id >= IdNavBase and id <= IdNavBase + int32(ord(high(Page))): | |
| 48 | - s.page = Page(id - IdNavBase) | |
| 49 | - return | |
| 50 | - case id | |
| 51 | - of IdDec: s[].apply(-s.step) | |
| 52 | - of IdInc: s[].apply(s.step) | |
| 53 | - of IdReset: s[].apply(-s.value) | |
| 54 | - of IdStepDown: s.step = max(1, s.step div 2) | |
| 55 | - of IdStepUp: s.step = min(100, s.step * 2) | |
| 56 | - of IdPokeStandard: s.poked = "standard" | |
| 57 | - of IdPokeSuggested: s.poked = "suggested" | |
| 58 | - of IdPokeDestructive: s.poked = "destructive" | |
| 59 | - of IdGapNarrower: s.gap = max(0, s.gap - 4) | |
| 60 | - of IdGapWider: s.gap = min(48, s.gap + 4) | |
| 61 | - else: discard | |
| 62 | - | |
| 63 | -proc navBar(b: Builder; s: ptr State) = | |
| 64 | - ## One button per page. The current page's button is inert, which is both | |
| 65 | - ## the highlight and the reason it cannot be pressed twice. | |
| 66 | - b.row: | |
| 67 | - b.spacing(space(SpaceXxs)) | |
| 68 | - b.alignCenter() | |
| 69 | - for p in Page: | |
| 70 | - b.button($p, IdNavBase + int32(ord(p)), | |
| 71 | - if p == s.page: ButtonSuggested else: ButtonText, | |
| 72 | - enabled = p != s.page) | |
| 73 | - | |
| 74 | -proc counterPage(b: Builder; s: ptr State) = | |
| 75 | - b.text(&"{s.value}", TextTitle1) | |
| 76 | - | |
| 77 | - b.row: | |
| 78 | - b.spacing(space(SpaceS)) | |
| 79 | - b.alignCenter() | |
| 80 | - b.button(&"−{s.step}", IdDec) | |
| 81 | - b.button("Reset", IdReset, ButtonDestructive, enabled = s.value != 0) | |
| 82 | - b.button(&"+{s.step}", IdInc, ButtonSuggested) | |
| 83 | - | |
| 84 | - b.row: | |
| 85 | - b.spacing(space(SpaceXs)) | |
| 86 | - b.alignCenter() | |
| 87 | - b.button("÷2", IdStepDown, ButtonText, enabled = s.step > 1) | |
| 88 | - b.text(&"step {s.step}", TextCaption) | |
| 89 | - b.button("×2", IdStepUp, ButtonText, enabled = s.step < 100) | |
| 90 | - | |
| 91 | - if s.history.len > 0: | |
| 92 | - b.text(s.history.join(" → ") & " → " & $s.value, TextCaption) | |
| 93 | - | |
| 94 | -proc textPage(b: Builder) = | |
| 95 | - ## Every style the binding exposes, labelled with itself. | |
| 96 | - b.column: | |
| 97 | - b.spacing(space(SpaceXxs)) | |
| 98 | - for style in TextStyle: | |
| 99 | - b.text($style, style) | |
| 100 | - | |
| 101 | -proc buttonsPage(b: Builder; s: ptr State) = | |
| 102 | - b.row: | |
| 103 | - b.spacing(space(SpaceS)) | |
| 104 | - b.alignCenter() | |
| 105 | - b.button("Standard", IdPokeStandard) | |
| 106 | - b.button("Suggested", IdPokeSuggested, ButtonSuggested) | |
| 107 | - b.button("Destructive", IdPokeDestructive, ButtonDestructive) | |
| 108 | - | |
| 109 | - b.row: | |
| 110 | - b.spacing(space(SpaceS)) | |
| 111 | - b.alignCenter() | |
| 112 | - b.button("Text", IdPokeStandard, ButtonText) | |
| 113 | - b.button("Link", IdPokeStandard, ButtonLink) | |
| 114 | - b.button("Disabled", IdPokeStandard, ButtonStandard, enabled = false) | |
| 115 | - | |
| 116 | - b.text( | |
| 117 | - if s.poked.len == 0: "none pressed yet" else: &"last pressed: {s.poked}", | |
| 118 | - TextCaption) | |
| 119 | - | |
| 120 | -proc layoutPage(b: Builder; s: ptr State) = | |
| 121 | - ## The gap between the boxes is host state, so the row is re-described at a | |
| 122 | - ## different spacing each time rather than being mutated in place. | |
| 123 | - b.text(&"row spacing: {s.gap}px", TextHeading) | |
| 124 | - | |
| 125 | - b.row: | |
| 126 | - b.spacing(float(s.gap)) | |
| 127 | - b.alignCenter() | |
| 128 | - for label in ["one", "two", "three"]: | |
| 129 | - b.container: | |
| 130 | - b.padding(space(SpaceXs)) | |
| 131 | - b.text(label, TextBody) | |
| 132 | - | |
| 133 | - b.row: | |
| 134 | - b.spacing(space(SpaceXs)) | |
| 135 | - b.alignCenter() | |
| 136 | - b.button("narrower", IdGapNarrower, ButtonText, enabled = s.gap > 0) | |
| 137 | - b.button("wider", IdGapWider, ButtonText, enabled = s.gap < 48) | |
| 138 | - | |
| 139 | - b.space(0, space(SpaceS)) | |
| 140 | - | |
| 141 | - b.text("theme spacing scale", TextHeading) | |
| 142 | - b.column: | |
| 143 | - b.spacing(space(SpaceXxxs)) | |
| 144 | - for step in SpaceStep: | |
| 145 | - b.text(&"{step}: {space(step):.0f}px", TextMonotext) | |
| 146 | - | |
| 147 | -proc onView(ctx: pointer; b: Builder) {.cdecl.} = | |
| 148 | - let s = cast[ptr State](ctx) | |
| 149 | - | |
| 150 | - b.container: | |
| 151 | - b.fill() | |
| 152 | - b.alignCenter() | |
| 153 | - b.spacing(space(SpaceM)) | |
| 154 | - | |
| 155 | - b.navBar(s) | |
| 156 | - | |
| 157 | - case s.page | |
| 158 | - of PageCounter: b.counterPage(s) | |
| 159 | - of PageText: b.textPage() | |
| 160 | - of PageButtons: b.buttonsPage(s) | |
| 161 | - of PageLayout: b.layoutPage(s) | |
| 162 | - | |
| 163 | -proc main() = | |
| 164 | - var state = State(page: PageCounter, value: 0, step: 1, gap: 12) | |
| 165 | - var config = CosmicConfig( | |
| 166 | - title: "Nim ❤ COSMIC", | |
| 167 | - onView: onView, | |
| 168 | - onPress: onPress, | |
| 169 | - ctx: addr state, | |
| 170 | - width: 560, | |
| 171 | - height: 460, | |
| 172 | - ) | |
| 173 | - let rc = cosmicRun(addr config) | |
| 174 | - if rc != 0: | |
| 175 | - quit(&"cosmic_run failed: {rc}", 1) | |
| 176 | - echo &"final count: {state.value}, last page: {state.page}" | |
| 177 | - | |
| 178 | -main() | |
| deleted file mode 100644 | |||
| @@ -1,178 +0,0 @@ | |||
| 1 | -## A small gallery of what the builder can express. | ||
| 2 | -## | ||
| 3 | -## Every frame the whole tree is rebuilt from Nim state, so switching pages | ||
| 4 | -## is not a widget being shown or hidden — it is a different tree. The same | ||
| 5 | -## goes for the disabled buttons and the history line: they are simply not | ||
| 6 | -## described when the state does not call for them. | ||
| 7 | - | ||
| 8 | -import std/[strformat, strutils] | ||
| 9 | -import cosmicnim | ||
| 10 | - | ||
| 11 | -type Page = enum | ||
| 12 | - PageCounter = "Counter" | ||
| 13 | - PageText = "Text" | ||
| 14 | - PageButtons = "Buttons" | ||
| 15 | - PageLayout = "Layout" | ||
| 16 | - | ||
| 17 | -const | ||
| 18 | - # Nav takes one id per page; pages take ids from 10 up. | ||
| 19 | - IdNavBase = 0'i32 | ||
| 20 | - IdDec = 10'i32 | ||
| 21 | - IdInc = 11'i32 | ||
| 22 | - IdReset = 12'i32 | ||
| 23 | - IdStepDown = 13'i32 | ||
| 24 | - IdStepUp = 14'i32 | ||
| 25 | - IdPokeStandard = 20'i32 | ||
| 26 | - IdPokeSuggested = 21'i32 | ||
| 27 | - IdPokeDestructive = 22'i32 | ||
| 28 | - IdGapNarrower = 30'i32 | ||
| 29 | - IdGapWider = 31'i32 | ||
| 30 | - | ||
| 31 | -type State = object | ||
| 32 | - page: Page | ||
| 33 | - value: int | ||
| 34 | - step: int | ||
| 35 | - history: seq[int] | ||
| 36 | - poked: string | ||
| 37 | - gap: int | ||
| 38 | - | ||
| 39 | -proc apply(s: var State; delta: int) = | ||
| 40 | - s.history.add s.value | ||
| 41 | - if s.history.len > 6: | ||
| 42 | - s.history.delete(0) | ||
| 43 | - s.value += delta | ||
| 44 | - | ||
| 45 | -proc onPress(ctx: pointer; id: int32) {.cdecl.} = | ||
| 46 | - let s = cast[ptr State](ctx) | ||
| 47 | - if id >= IdNavBase and id <= IdNavBase + int32(ord(high(Page))): | ||
| 48 | - s.page = Page(id - IdNavBase) | ||
| 49 | - return | ||
| 50 | - case id | ||
| 51 | - of IdDec: s[].apply(-s.step) | ||
| 52 | - of IdInc: s[].apply(s.step) | ||
| 53 | - of IdReset: s[].apply(-s.value) | ||
| 54 | - of IdStepDown: s.step = max(1, s.step div 2) | ||
| 55 | - of IdStepUp: s.step = min(100, s.step * 2) | ||
| 56 | - of IdPokeStandard: s.poked = "standard" | ||
| 57 | - of IdPokeSuggested: s.poked = "suggested" | ||
| 58 | - of IdPokeDestructive: s.poked = "destructive" | ||
| 59 | - of IdGapNarrower: s.gap = max(0, s.gap - 4) | ||
| 60 | - of IdGapWider: s.gap = min(48, s.gap + 4) | ||
| 61 | - else: discard | ||
| 62 | - | ||
| 63 | -proc navBar(b: Builder; s: ptr State) = | ||
| 64 | - ## One button per page. The current page's button is inert, which is both | ||
| 65 | - ## the highlight and the reason it cannot be pressed twice. | ||
| 66 | - b.row: | ||
| 67 | - b.spacing(space(SpaceXxs)) | ||
| 68 | - b.alignCenter() | ||
| 69 | - for p in Page: | ||
| 70 | - b.button($p, IdNavBase + int32(ord(p)), | ||
| 71 | - if p == s.page: ButtonSuggested else: ButtonText, | ||
| 72 | - enabled = p != s.page) | ||
| 73 | - | ||
| 74 | -proc counterPage(b: Builder; s: ptr State) = | ||
| 75 | - b.text(&"{s.value}", TextTitle1) | ||
| 76 | - | ||
| 77 | - b.row: | ||
| 78 | - b.spacing(space(SpaceS)) | ||
| 79 | - b.alignCenter() | ||
| 80 | - b.button(&"−{s.step}", IdDec) | ||
| 81 | - b.button("Reset", IdReset, ButtonDestructive, enabled = s.value != 0) | ||
| 82 | - b.button(&"+{s.step}", IdInc, ButtonSuggested) | ||
| 83 | - | ||
| 84 | - b.row: | ||
| 85 | - b.spacing(space(SpaceXs)) | ||
| 86 | - b.alignCenter() | ||
| 87 | - b.button("÷2", IdStepDown, ButtonText, enabled = s.step > 1) | ||
| 88 | - b.text(&"step {s.step}", TextCaption) | ||
| 89 | - b.button("×2", IdStepUp, ButtonText, enabled = s.step < 100) | ||
| 90 | - | ||
| 91 | - if s.history.len > 0: | ||
| 92 | - b.text(s.history.join(" → ") & " → " & $s.value, TextCaption) | ||
| 93 | - | ||
| 94 | -proc textPage(b: Builder) = | ||
| 95 | - ## Every style the binding exposes, labelled with itself. | ||
| 96 | - b.column: | ||
| 97 | - b.spacing(space(SpaceXxs)) | ||
| 98 | - for style in TextStyle: | ||
| 99 | - b.text($style, style) | ||
| 100 | - | ||
| 101 | -proc buttonsPage(b: Builder; s: ptr State) = | ||
| 102 | - b.row: | ||
| 103 | - b.spacing(space(SpaceS)) | ||
| 104 | - b.alignCenter() | ||
| 105 | - b.button("Standard", IdPokeStandard) | ||
| 106 | - b.button("Suggested", IdPokeSuggested, ButtonSuggested) | ||
| 107 | - b.button("Destructive", IdPokeDestructive, ButtonDestructive) | ||
| 108 | - | ||
| 109 | - b.row: | ||
| 110 | - b.spacing(space(SpaceS)) | ||
| 111 | - b.alignCenter() | ||
| 112 | - b.button("Text", IdPokeStandard, ButtonText) | ||
| 113 | - b.button("Link", IdPokeStandard, ButtonLink) | ||
| 114 | - b.button("Disabled", IdPokeStandard, ButtonStandard, enabled = false) | ||
| 115 | - | ||
| 116 | - b.text( | ||
| 117 | - if s.poked.len == 0: "none pressed yet" else: &"last pressed: {s.poked}", | ||
| 118 | - TextCaption) | ||
| 119 | - | ||
| 120 | -proc layoutPage(b: Builder; s: ptr State) = | ||
| 121 | - ## The gap between the boxes is host state, so the row is re-described at a | ||
| 122 | - ## different spacing each time rather than being mutated in place. | ||
| 123 | - b.text(&"row spacing: {s.gap}px", TextHeading) | ||
| 124 | - | ||
| 125 | - b.row: | ||
| 126 | - b.spacing(float(s.gap)) | ||
| 127 | - b.alignCenter() | ||
| 128 | - for label in ["one", "two", "three"]: | ||
| 129 | - b.container: | ||
| 130 | - b.padding(space(SpaceXs)) | ||
| 131 | - b.text(label, TextBody) | ||
| 132 | - | ||
| 133 | - b.row: | ||
| 134 | - b.spacing(space(SpaceXs)) | ||
| 135 | - b.alignCenter() | ||
| 136 | - b.button("narrower", IdGapNarrower, ButtonText, enabled = s.gap > 0) | ||
| 137 | - b.button("wider", IdGapWider, ButtonText, enabled = s.gap < 48) | ||
| 138 | - | ||
| 139 | - b.space(0, space(SpaceS)) | ||
| 140 | - | ||
| 141 | - b.text("theme spacing scale", TextHeading) | ||
| 142 | - b.column: | ||
| 143 | - b.spacing(space(SpaceXxxs)) | ||
| 144 | - for step in SpaceStep: | ||
| 145 | - b.text(&"{step}: {space(step):.0f}px", TextMonotext) | ||
| 146 | - | ||
| 147 | -proc onView(ctx: pointer; b: Builder) {.cdecl.} = | ||
| 148 | - let s = cast[ptr State](ctx) | ||
| 149 | - | ||
| 150 | - b.container: | ||
| 151 | - b.fill() | ||
| 152 | - b.alignCenter() | ||
| 153 | - b.spacing(space(SpaceM)) | ||
| 154 | - | ||
| 155 | - b.navBar(s) | ||
| 156 | - | ||
| 157 | - case s.page | ||
| 158 | - of PageCounter: b.counterPage(s) | ||
| 159 | - of PageText: b.textPage() | ||
| 160 | - of PageButtons: b.buttonsPage(s) | ||
| 161 | - of PageLayout: b.layoutPage(s) | ||
| 162 | - | ||
| 163 | -proc main() = | ||
| 164 | - var state = State(page: PageCounter, value: 0, step: 1, gap: 12) | ||
| 165 | - var config = CosmicConfig( | ||
| 166 | - title: "Nim ❤ COSMIC", | ||
| 167 | - onView: onView, | ||
| 168 | - onPress: onPress, | ||
| 169 | - ctx: addr state, | ||
| 170 | - width: 560, | ||
| 171 | - height: 460, | ||
| 172 | - ) | ||
| 173 | - let rc = cosmicRun(addr config) | ||
| 174 | - if rc != 0: | ||
| 175 | - quit(&"cosmic_run failed: {rc}", 1) | ||
| 176 | - echo &"final count: {state.value}, last page: {state.page}" | ||
| 177 | - | ||
| 178 | -main() | ||
modified
justfile +7 -3 | @@ -7,11 +7,11 @@ default: run | ||
| 7 | 7 | |
| 8 | 8 | # Run the demo gallery. Needs nim/libcosmic_ffi.so (see `fetch` or `build`). |
| 9 | 9 | run: _have-so |
| 10 | - nim c -r --path:nim examples/gallery.nim | |
| 10 | + nim c -r --path:nim examples/calculator.nim | |
| 11 | 11 | |
| 12 | 12 | # Type-check the Nim side. Needs no .so: the binding loads it lazily. |
| 13 | 13 | check: |
| 14 | - nim check --path:nim examples/gallery.nim | |
| 14 | + nim check --path:nim examples/calculator.nim | |
| 15 | 15 | |
| 16 | 16 | # Build the cdylib from source into nim/ (cold builds are slow; CI is faster) |
| 17 | 17 | build: |
| @@ -43,7 +43,11 @@ tag version: | ||
| 43 | 43 | git push "$REMOTE" "{{version}}" |
| 44 | 44 | |
| 45 | 45 | clean: |
| 46 | - rm -rf cosmic_ffi/target examples/nimcache examples/gallery | |
| 46 | + rm -rf cosmic_ffi/target examples/nimcache examples/calculator | |
| 47 | 47 | |
| 48 | 48 | _have-so: |
| 49 | 49 | @test -f {{so}} || { echo "missing {{so}} -- run 'just build' or 'just fetch <url>'" >&2; exit 1; } |
| 50 | + | |
| 51 | +# Run the headless tests of the example's logic (no window, no cdylib call). | |
| 52 | +test: | |
| 53 | + nim r --hints:off --path:nim tests/tcalculator.nim | |
| @@ -7,11 +7,11 @@ default: run | |||
| 7 | 7 | ||
| 8 | # Run the demo gallery. Needs nim/libcosmic_ffi.so (see `fetch` or `build`). | 8 | # Run the demo gallery. Needs nim/libcosmic_ffi.so (see `fetch` or `build`). |
| 9 | run: _have-so | 9 | run: _have-so |
| 10 | - nim c -r --path:nim examples/gallery.nim | 10 | + nim c -r --path:nim examples/calculator.nim |
| 11 | 11 | ||
| 12 | # Type-check the Nim side. Needs no .so: the binding loads it lazily. | 12 | # Type-check the Nim side. Needs no .so: the binding loads it lazily. |
| 13 | check: | 13 | check: |
| 14 | - nim check --path:nim examples/gallery.nim | 14 | + nim check --path:nim examples/calculator.nim |
| 15 | 15 | ||
| 16 | # Build the cdylib from source into nim/ (cold builds are slow; CI is faster) | 16 | # Build the cdylib from source into nim/ (cold builds are slow; CI is faster) |
| 17 | build: | 17 | build: |
| @@ -43,7 +43,11 @@ tag version: | |||
| 43 | git push "$REMOTE" "{{version}}" | 43 | git push "$REMOTE" "{{version}}" |
| 44 | 44 | ||
| 45 | clean: | 45 | clean: |
| 46 | - rm -rf cosmic_ffi/target examples/nimcache examples/gallery | 46 | + rm -rf cosmic_ffi/target examples/nimcache examples/calculator |
| 47 | 47 | ||
| 48 | _have-so: | 48 | _have-so: |
| 49 | @test -f {{so}} || { echo "missing {{so}} -- run 'just build' or 'just fetch <url>'" >&2; exit 1; } | 49 | @test -f {{so}} || { echo "missing {{so}} -- run 'just build' or 'just fetch <url>'" >&2; exit 1; } |
| 50 | + | ||
| 51 | +# Run the headless tests of the example's logic (no window, no cdylib call). | ||
| 52 | +test: | ||
| 53 | + nim r --hints:off --path:nim tests/tcalculator.nim | ||
modified
nim/cosmicnim.nim +7 -0 | @@ -73,6 +73,8 @@ proc rawButton(b: Builder; style: int32; label: cstring; id: int32) | ||
| 73 | 73 | {.cdecl, importc: "cosmic_button", dynlib: libCosmicFfi.} |
| 74 | 74 | proc rawSpace(b: Builder; w, h: cfloat) |
| 75 | 75 | {.cdecl, importc: "cosmic_space", dynlib: libCosmicFfi.} |
| 76 | +proc rawSize(b: Builder; w, h: cfloat) | |
| 77 | + {.cdecl, importc: "cosmic_size", dynlib: libCosmicFfi.} | |
| 76 | 78 | proc rawSpaceUnit(step: int32): cfloat |
| 77 | 79 | {.cdecl, importc: "cosmic_space_unit", dynlib: libCosmicFfi.} |
| 78 | 80 | |
| @@ -116,6 +118,11 @@ proc button*(b: Builder; label: string; id: int32; style = ButtonStandard; | ||
| 116 | 118 | |
| 117 | 119 | proc space*(b: Builder; w, h: float) = rawSpace(b, cfloat(w), cfloat(h)) |
| 118 | 120 | |
| 121 | +proc size*(b: Builder; w = -1.0; h = -1.0) = | |
| 122 | + ## Size the next leaf, in pixels. A negative dimension stays natural. | |
| 123 | + ## Applies to one leaf only, so set it before each widget you want sized. | |
| 124 | + rawSize(b, cfloat(w), cfloat(h)) | |
| 125 | + | |
| 119 | 126 | proc space*(step: SpaceStep): float = |
| 120 | 127 | ## The active COSMIC theme's spacing for `step`, in pixels. |
| 121 | 128 | float(rawSpaceUnit(int32(ord(step)))) |
| @@ -73,6 +73,8 @@ proc rawButton(b: Builder; style: int32; label: cstring; id: int32) | |||
| 73 | {.cdecl, importc: "cosmic_button", dynlib: libCosmicFfi.} | 73 | {.cdecl, importc: "cosmic_button", dynlib: libCosmicFfi.} |
| 74 | proc rawSpace(b: Builder; w, h: cfloat) | 74 | proc rawSpace(b: Builder; w, h: cfloat) |
| 75 | {.cdecl, importc: "cosmic_space", dynlib: libCosmicFfi.} | 75 | {.cdecl, importc: "cosmic_space", dynlib: libCosmicFfi.} |
| 76 | +proc rawSize(b: Builder; w, h: cfloat) | ||
| 77 | + {.cdecl, importc: "cosmic_size", dynlib: libCosmicFfi.} | ||
| 76 | proc rawSpaceUnit(step: int32): cfloat | 78 | proc rawSpaceUnit(step: int32): cfloat |
| 77 | {.cdecl, importc: "cosmic_space_unit", dynlib: libCosmicFfi.} | 79 | {.cdecl, importc: "cosmic_space_unit", dynlib: libCosmicFfi.} |
| 78 | 80 | ||
| @@ -116,6 +118,11 @@ proc button*(b: Builder; label: string; id: int32; style = ButtonStandard; | |||
| 116 | 118 | ||
| 117 | proc space*(b: Builder; w, h: float) = rawSpace(b, cfloat(w), cfloat(h)) | 119 | proc space*(b: Builder; w, h: float) = rawSpace(b, cfloat(w), cfloat(h)) |
| 118 | 120 | ||
| 121 | +proc size*(b: Builder; w = -1.0; h = -1.0) = | ||
| 122 | + ## Size the next leaf, in pixels. A negative dimension stays natural. | ||
| 123 | + ## Applies to one leaf only, so set it before each widget you want sized. | ||
| 124 | + rawSize(b, cfloat(w), cfloat(h)) | ||
| 125 | + | ||
| 119 | proc space*(step: SpaceStep): float = | 126 | proc space*(step: SpaceStep): float = |
| 120 | ## The active COSMIC theme's spacing for `step`, in pixels. | 127 | ## The active COSMIC theme's spacing for `step`, in pixels. |
| 121 | float(rawSpaceUnit(int32(ord(step)))) | 128 | float(rawSpaceUnit(int32(ord(step)))) |
added
tests/nim.cfg +2 -0 | new file mode 100644 | ||
| @@ -0,0 +1,2 @@ | ||
| 1 | +# The tests never open a window, but they import the binding. | |
| 2 | +-d:noSignalHandler | |
| new file mode 100644 | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | +# The tests never open a window, but they import the binding. | ||
| 2 | +-d:noSignalHandler | ||
added
tests/tcalculator.nim +75 -0 | new file mode 100644 | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +## The keypad logic is pure Nim, so it can be driven without a window: | |
| 2 | +## press keys straight into onPress and read back what the display would say. | |
| 3 | + | |
| 4 | +import std/unittest | |
| 5 | +import ../examples/calculator | |
| 6 | + | |
| 7 | +proc press(c: var Calc; ids: varargs[int32]): string = | |
| 8 | + for id in ids: | |
| 9 | + onPress(addr c, id) | |
| 10 | + c.display | |
| 11 | + | |
| 12 | +proc fresh(): Calc = newCalc() | |
| 13 | + | |
| 14 | +suite "calculator": | |
| 15 | + test "digits accumulate into one entry": | |
| 16 | + var c = fresh() | |
| 17 | + check c.press(1, 2, 3) == "123" | |
| 18 | + | |
| 19 | + test "no leading zeros": | |
| 20 | + var c = fresh() | |
| 21 | + check c.press(0, 0, 5) == "5" | |
| 22 | + | |
| 23 | + test "addition": | |
| 24 | + var c = fresh() | |
| 25 | + check c.press(2, IdAdd, 3, IdEquals) == "5" | |
| 26 | + | |
| 27 | + test "chaining resolves the pending op as you go": | |
| 28 | + var c = fresh() | |
| 29 | + check c.press(2, IdAdd, 3, IdMul, 4) == "4" # 2+3 folded, 4 being typed | |
| 30 | + check c.press(IdEquals) == "20" # then (5)*4 | |
| 31 | + | |
| 32 | + test "subtraction and division": | |
| 33 | + var c = fresh() | |
| 34 | + check c.press(9, IdSub, 4, IdEquals) == "5" | |
| 35 | + c = fresh() | |
| 36 | + check c.press(8, IdDiv, 2, IdEquals) == "4" | |
| 37 | + | |
| 38 | + test "division by zero errors, and only C recovers": | |
| 39 | + var c = fresh() | |
| 40 | + check c.press(1, IdDiv, 0, IdEquals) == "Error" | |
| 41 | + check c.press(5) == "Error" # other keys are ignored | |
| 42 | + check c.press(IdClear) == "0" | |
| 43 | + check c.press(7) == "7" # usable again | |
| 44 | + | |
| 45 | + test "decimals": | |
| 46 | + var c = fresh() | |
| 47 | + check c.press(1, IdDot, 5, IdAdd, 2, IdDot, 2, 5, IdEquals) == "3.75" | |
| 48 | + | |
| 49 | + test "a bare dot starts at zero": | |
| 50 | + var c = fresh() | |
| 51 | + check c.press(IdDot, 5) == "0.5" | |
| 52 | + | |
| 53 | + test "one dot per number": | |
| 54 | + var c = fresh() | |
| 55 | + check c.press(1, IdDot, 2, IdDot, 3) == "1.23" | |
| 56 | + | |
| 57 | + test "sign flips the entry, then the result": | |
| 58 | + var c = fresh() | |
| 59 | + check c.press(4, IdSign) == "-4" | |
| 60 | + check c.press(IdSign) == "4" | |
| 61 | + c = fresh() | |
| 62 | + check c.press(2, IdAdd, 3, IdEquals, IdSign) == "-5" | |
| 63 | + | |
| 64 | + test "percent": | |
| 65 | + var c = fresh() | |
| 66 | + check c.press(5, 0, IdPercent) == "0.5" | |
| 67 | + | |
| 68 | + test "integers do not grow a decimal point": | |
| 69 | + var c = fresh() | |
| 70 | + check c.press(6, IdDiv, 3, IdEquals) == "2" | |
| 71 | + | |
| 72 | + test "a new number replaces the result, not appends to it": | |
| 73 | + var c = fresh() | |
| 74 | + check c.press(2, IdAdd, 3, IdEquals) == "5" | |
| 75 | + check c.press(7) == "7" | |
| new file mode 100644 | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | +## The keypad logic is pure Nim, so it can be driven without a window: | ||
| 2 | +## press keys straight into onPress and read back what the display would say. | ||
| 3 | + | ||
| 4 | +import std/unittest | ||
| 5 | +import ../examples/calculator | ||
| 6 | + | ||
| 7 | +proc press(c: var Calc; ids: varargs[int32]): string = | ||
| 8 | + for id in ids: | ||
| 9 | + onPress(addr c, id) | ||
| 10 | + c.display | ||
| 11 | + | ||
| 12 | +proc fresh(): Calc = newCalc() | ||
| 13 | + | ||
| 14 | +suite "calculator": | ||
| 15 | + test "digits accumulate into one entry": | ||
| 16 | + var c = fresh() | ||
| 17 | + check c.press(1, 2, 3) == "123" | ||
| 18 | + | ||
| 19 | + test "no leading zeros": | ||
| 20 | + var c = fresh() | ||
| 21 | + check c.press(0, 0, 5) == "5" | ||
| 22 | + | ||
| 23 | + test "addition": | ||
| 24 | + var c = fresh() | ||
| 25 | + check c.press(2, IdAdd, 3, IdEquals) == "5" | ||
| 26 | + | ||
| 27 | + test "chaining resolves the pending op as you go": | ||
| 28 | + var c = fresh() | ||
| 29 | + check c.press(2, IdAdd, 3, IdMul, 4) == "4" # 2+3 folded, 4 being typed | ||
| 30 | + check c.press(IdEquals) == "20" # then (5)*4 | ||
| 31 | + | ||
| 32 | + test "subtraction and division": | ||
| 33 | + var c = fresh() | ||
| 34 | + check c.press(9, IdSub, 4, IdEquals) == "5" | ||
| 35 | + c = fresh() | ||
| 36 | + check c.press(8, IdDiv, 2, IdEquals) == "4" | ||
| 37 | + | ||
| 38 | + test "division by zero errors, and only C recovers": | ||
| 39 | + var c = fresh() | ||
| 40 | + check c.press(1, IdDiv, 0, IdEquals) == "Error" | ||
| 41 | + check c.press(5) == "Error" # other keys are ignored | ||
| 42 | + check c.press(IdClear) == "0" | ||
| 43 | + check c.press(7) == "7" # usable again | ||
| 44 | + | ||
| 45 | + test "decimals": | ||
| 46 | + var c = fresh() | ||
| 47 | + check c.press(1, IdDot, 5, IdAdd, 2, IdDot, 2, 5, IdEquals) == "3.75" | ||
| 48 | + | ||
| 49 | + test "a bare dot starts at zero": | ||
| 50 | + var c = fresh() | ||
| 51 | + check c.press(IdDot, 5) == "0.5" | ||
| 52 | + | ||
| 53 | + test "one dot per number": | ||
| 54 | + var c = fresh() | ||
| 55 | + check c.press(1, IdDot, 2, IdDot, 3) == "1.23" | ||
| 56 | + | ||
| 57 | + test "sign flips the entry, then the result": | ||
| 58 | + var c = fresh() | ||
| 59 | + check c.press(4, IdSign) == "-4" | ||
| 60 | + check c.press(IdSign) == "4" | ||
| 61 | + c = fresh() | ||
| 62 | + check c.press(2, IdAdd, 3, IdEquals, IdSign) == "-5" | ||
| 63 | + | ||
| 64 | + test "percent": | ||
| 65 | + var c = fresh() | ||
| 66 | + check c.press(5, 0, IdPercent) == "0.5" | ||
| 67 | + | ||
| 68 | + test "integers do not grow a decimal point": | ||
| 69 | + var c = fresh() | ||
| 70 | + check c.press(6, IdDiv, 3, IdEquals) == "2" | ||
| 71 | + | ||
| 72 | + test "a new number replaces the result, not appends to it": | ||
| 73 | + var c = fresh() | ||
| 74 | + check c.press(2, IdAdd, 3, IdEquals) == "5" | ||
| 75 | + check c.press(7) == "7" | ||