nandi/cosmicnimpublic Fork 0
dee3349
Commits
Clone
git clone https://git.rickub.com/nandi/cosmicnim.git
git clone ssh://git@rickub.com/nandi/cosmicnim.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandithebull committed 2026-09-20T11:10:49-07:00 Browse files
dee3349 parent: fcee0b9
modified .gitignore +4 -2
@@ -1,6 +1,8 @@
11 target/
2-examples/gallery
3-nim/app
2+examples/*
3+!examples/*.nim
4+!examples/nim.cfg
5+tests/tcalculator
46 nimcache/
57 *.so
68 run.log
@@ -1,6 +1,8 @@
1 target/1 target/
2-examples/gallery2+examples/*
3-nim/app3+!examples/*.nim
4+!examples/nim.cfg
5+tests/tcalculator
4 nimcache/6 nimcache/
5 *.so7 *.so
6 run.log8 run.log
modified README.md +4 -2
@@ -10,7 +10,8 @@ crate and exports a handful of functions.
1010 ```
1111 cosmic_ffi/ Rust cdylib -> libcosmic_ffi.so + cosmic_ffi.h
1212 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
1415 cosmicnim.nimble package metadata and the fetchLib task
1516 justfile build / run / fetch tasks
1617 ```
@@ -64,7 +65,8 @@ The demo needs `nim/libcosmic_ffi.so`. Take it from a release:
6465
6566 ```bash
6667 just fetch # or: nimble fetchLib
67-just run
68+just run # the calculator
69+just test # its arithmetic, headless — no window, no cdylib call
6870 ```
6971
7072 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.h11 cosmic_ffi/ Rust cdylib -> libcosmic_ffi.so + cosmic_ffi.h
12 nim/ the Nim binding (cosmicnim.nim), and where the .so lands12 nim/ the Nim binding (cosmicnim.nim), and where the .so lands
13-examples/ gallery.nim, a demo of what the builder can express13+examples/ calculator.nim, a worked example
14+tests/ headless tests of the example's logic
14 cosmicnim.nimble package metadata and the fetchLib task15 cosmicnim.nimble package metadata and the fetchLib task
15 justfile build / run / fetch tasks16 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 ```bash66 ```bash
66 just fetch # or: nimble fetchLib67 just fetch # or: nimble fetchLib
67-just run68+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 the72 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 = [
964964
965965 [[package]]
966966 name = "cosmic_ffi"
967-version = "0.2.0"
967+version = "0.3.0"
968968 dependencies = [
969969 "libcosmic",
970970 ]
@@ -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 @@
11 [package]
22 name = "cosmic_ffi"
3-version = "0.2.0"
3+version = "0.3.0"
44 edition = "2021"
55
66 [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,
6363
6464 void cosmic_space(CosmicBuilder *b, float w, float h);
6565
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+
6670 /* --- the active theme's spacing scale, for laying out in COSMIC's rhythm --- */
6771 #define COSMIC_SPACE_NONE 0
6872 #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 071 #define COSMIC_SPACE_NONE 0
68 #define COSMIC_SPACE_XXXS 172 #define COSMIC_SPACE_XXXS 1
modified cosmic_ffi/src/lib.rs +47 -11
@@ -121,12 +121,16 @@ impl Frame {
121121 pub struct Builder {
122122 /// Always non-empty: `stack[0]` is the implicit root.
123123 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),
124127 }
125128
126129 impl Builder {
127130 fn new() -> Self {
128131 Builder {
129132 stack: vec![Frame::new(Kind::Container)],
133+ next_size: (-1.0, -1.0),
130134 }
131135 }
132136
@@ -148,6 +152,15 @@ impl Builder {
148152 }
149153 }
150154
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+
151164 fn leaf(&mut self, element: Element) {
152165 self.top().children.push(element);
153166 }
@@ -220,6 +233,13 @@ pub unsafe extern "C" fn cosmic_fill(b: *mut Builder) {
220233 builder!(b).top().fill = true;
221234 }
222235
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+
223243 /// A blank gap of `w` by `h` pixels.
224244 #[no_mangle]
225245 pub unsafe extern "C" fn cosmic_space(b: *mut Builder, w: f32, h: f32) {
@@ -246,16 +266,24 @@ pub const TEXT_MONOTEXT: i32 = 7;
246266 pub unsafe extern "C" fn cosmic_text(b: *mut Builder, style: i32, text: *const c_char) {
247267 let b = builder!(b);
248268 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())
259287 }
260288
261289 /// Button styles for [`cosmic_button`].
@@ -271,11 +299,19 @@ pub const BUTTON_LINK: i32 = 4;
271299 pub unsafe extern "C" fn cosmic_button(b: *mut Builder, style: i32, label: *const c_char, id: i32) {
272300 let b = builder!(b);
273301 let s = str_or(label, "");
302+ let (bw, bh) = b.take_size();
274303 // Each constructor returns a differently-typed `button::Builder`, so the
275304 // arms cannot unify: finish each one into an Element where it is built.
276305 macro_rules! finish {
277306 ($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+ }
279315 if id < 0 {
280316 button.into()
281317 } 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 the303 // 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"
22 author = "nandi"
33 description = "libcosmic behind a C ABI, driven from Nim"
44 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()
modified justfile +7 -3
@@ -7,11 +7,11 @@ default: run
77
88 # Run the demo gallery. Needs nim/libcosmic_ffi.so (see `fetch` or `build`).
99 run: _have-so
10- nim c -r --path:nim examples/gallery.nim
10+ nim c -r --path:nim examples/calculator.nim
1111
1212 # Type-check the Nim side. Needs no .so: the binding loads it lazily.
1313 check:
14- nim check --path:nim examples/gallery.nim
14+ nim check --path:nim examples/calculator.nim
1515
1616 # Build the cdylib from source into nim/ (cold builds are slow; CI is faster)
1717 build:
@@ -43,7 +43,11 @@ tag version:
4343 git push "$REMOTE" "{{version}}"
4444
4545 clean:
46- rm -rf cosmic_ffi/target examples/nimcache examples/gallery
46+ rm -rf cosmic_ffi/target examples/nimcache examples/calculator
4747
4848 _have-so:
4949 @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-so9 run: _have-so
10- nim c -r --path:nim examples/gallery.nim10+ 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.nim14+ 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/gallery46+ 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)
7373 {.cdecl, importc: "cosmic_button", dynlib: libCosmicFfi.}
7474 proc rawSpace(b: Builder; w, h: cfloat)
7575 {.cdecl, importc: "cosmic_space", dynlib: libCosmicFfi.}
76+proc rawSize(b: Builder; w, h: cfloat)
77+ {.cdecl, importc: "cosmic_size", dynlib: libCosmicFfi.}
7678 proc rawSpaceUnit(step: int32): cfloat
7779 {.cdecl, importc: "cosmic_space_unit", dynlib: libCosmicFfi.}
7880
@@ -116,6 +118,11 @@ proc button*(b: Builder; label: string; id: int32; style = ButtonStandard;
116118
117119 proc space*(b: Builder; w, h: float) = rawSpace(b, cfloat(w), cfloat(h))
118120
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+
119126 proc space*(step: SpaceStep): float =
120127 ## The active COSMIC theme's spacing for `step`, in pixels.
121128 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): cfloat78 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"