diff --git a/Cargo.lock b/Cargo.lock index 6873575..cb8a20d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,121 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "serde", + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" @@ -26,7 +141,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -40,6 +157,52 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -49,6 +212,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "approx" version = "0.5.1" @@ -76,6 +245,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -88,6 +277,141 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -105,6 +429,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -213,6 +574,15 @@ dependencies = [ "bit-vec 0.8.0", ] +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", +] + [[package]] name = "bit-set" version = "0.11.1" @@ -228,6 +598,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bit-vec" version = "0.10.1" @@ -238,6 +614,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -266,6 +648,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.8.1" @@ -290,6 +703,27 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -343,6 +777,56 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" version = "1.7.0" @@ -367,6 +851,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -458,6 +951,15 @@ dependencies = [ "serde_path_to_error", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmov" version = "0.5.4" @@ -470,7 +972,18 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.21", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", ] [[package]] @@ -497,6 +1010,26 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "comemo" version = "0.5.1" @@ -505,7 +1038,7 @@ checksum = "3c963350b2b08aa4b725d7802593245380ab53dacfedcaa971385fc33306c0d4" dependencies = [ "comemo-macros", "parking_lot", - "rustc-hash", + "rustc-hash 2.1.3", "siphasher", "slab", ] @@ -521,6 +1054,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -536,12 +1078,46 @@ dependencies = [ "rustversion", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -670,6 +1246,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "darling" version = "0.24.1" @@ -716,6 +1298,37 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "deranged" version = "0.5.8" @@ -733,6 +1346,19 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "digest" version = "0.10.7" @@ -774,7 +1400,23 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", ] [[package]] @@ -788,12 +1430,53 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", + "serde", +] + [[package]] name = "ecow" version = "0.2.6" @@ -803,12 +1486,212 @@ dependencies = [ "serde", ] +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster", + "profiling", + "raw-window-handle", + "ron", + "serde", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "ron", + "serde", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-phosphor" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4299551488dddaa8f757727e1eb4ffdaf277ffe6e30b413403468f5ecf4634" +dependencies = [ + "egui", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "serde", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +dependencies = [ + "ahash", + "egui", + "enum-map", + "image", + "itertools", + "jiff", + "log", + "mime_guess2", + "profiling", + "serde", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster", + "serde", + "tempfile", + "toml 1.1.8+spec-1.1.0", + "wgpu", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", + "serde", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", + "serde", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enum-ordinalize" version = "4.4.2" @@ -829,6 +1712,70 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types 0.12.6", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "serde", + "skrifa 0.44.0", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu 0.1.0", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + [[package]] name = "equivalent" version = "1.0.2" @@ -842,9 +1789,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "euclid" version = "0.22.14" @@ -854,6 +1807,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -883,6 +1856,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -898,6 +1877,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "fearless_simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4beca3cb2444e3304ac30843cc091f44ed58932353cd492ce740067bfce6b12" + [[package]] name = "fearless_simd" version = "1.1.0" @@ -951,6 +1936,16 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", + "serde", +] + [[package]] name = "fontconfig-parser" version = "0.5.8" @@ -974,6 +1969,33 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -983,6 +2005,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.34" @@ -1031,6 +2062,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1081,6 +2125,25 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1128,6 +2191,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glidesort" version = "0.1.2" @@ -1146,11 +2220,136 @@ dependencies = [ "log", "peniko", "png", - "skrifa", + "skrifa 0.42.1", "smallvec", "vello_common 0.0.9", ] +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.1.0", +] + +[[package]] +name = "glifo" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1218dedd5348c1b166ec3b8b3cbca49d2f527bd43b3fa072ea5123c523ed3f" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.3.0", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + [[package]] name = "guillotiere" version = "0.7.0" @@ -1168,9 +2367,22 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts 0.41.0", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1180,6 +2392,17 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1214,7 +2437,7 @@ dependencies = [ "roman-numerals-rs", "serde", "serde_yaml", - "thiserror", + "thiserror 2.0.21", "unic-langid", "unicode-segmentation", "unscanny", @@ -1228,11 +2451,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" dependencies = [ "bytemuck", - "hayro-interpret", + "hayro-interpret 0.7.0", "image", "kurbo", "pic-scale", - "vello_cpu", + "vello_cpu 0.0.8", +] + +[[package]] +name = "hayro" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4675ff5acd7ca41c0c34ccd2a3a154f5c86d8b5ec92bef0a114bfb194969e2af" +dependencies = [ + "bytemuck", + "fearless_simd 1.1.0", + "hayro-interpret 0.8.0", + "image", + "kurbo", + "pic-scale", + "rustc-hash 2.1.3", + "vello_cpu 0.3.0", ] [[package]] @@ -1253,6 +2492,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" dependencies = [ + "brotli", "hayro-postscript", ] @@ -1262,15 +2502,34 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "hayro-cmap", - "hayro-syntax", + "hayro-syntax 0.7.2", "kurbo", "moxcms", - "phf", - "rustc-hash", + "phf 0.13.1", + "rustc-hash 2.1.3", "siphasher", - "skrifa", + "skrifa 0.42.1", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-interpret" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax 0.8.0", + "kurbo", + "moxcms", + "phf 0.13.1", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.46.2", "smallvec", "yoke", ] @@ -1294,6 +2553,15 @@ dependencies = [ "fearless_simd 0.4.1", ] +[[package]] +name = "hayro-jpeg2000" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835" +dependencies = [ + "fearless_simd 1.1.0", +] + [[package]] name = "hayro-postscript" version = "0.1.0" @@ -1307,7 +2575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7434c89e920d84e077214a29e69b462a6518754610f732f1124af3948410cb8c" dependencies = [ "base64 0.22.1", - "hayro-interpret", + "hayro-interpret 0.7.0", "image", "kurbo", "siphasher", @@ -1323,9 +2591,25 @@ dependencies = [ "flate2", "hayro-ccitt 0.3.0", "hayro-jbig2", - "hayro-jpeg2000", + "hayro-jpeg2000 0.3.5", "memchr", - "rustc-hash", + "rustc-hash 2.1.3", + "smallvec", + "zune-jpeg", +] + +[[package]] +name = "hayro-syntax" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026" +dependencies = [ + "flate2", + "hayro-ccitt 0.4.0", + "hayro-jbig2", + "hayro-jpeg2000 0.4.1", + "memchr", + "rustc-hash 2.1.3", "smallvec", "zune-jpeg", ] @@ -1337,7 +2621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a" dependencies = [ "flate2", - "hayro-syntax", + "hayro-syntax 0.7.2", "pdf-writer", ] @@ -1347,6 +2631,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1717,6 +3007,7 @@ dependencies = [ "moxcms", "num-traits", "png", + "tiff", "zune-core", "zune-jpeg", ] @@ -1756,6 +3047,26 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.2.2" @@ -1765,12 +3076,153 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "js-sys", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -1801,6 +3253,53 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + [[package]] name = "krilla" version = "0.8.2" @@ -1820,10 +3319,10 @@ dependencies = [ "pdf-writer", "png", "rayon", - "rustc-hash", + "rustc-hash 2.1.3", "rustybuzz", "siphasher", - "skrifa", + "skrifa 0.42.1", "smallvec", "subsetter", "tiny-skia-path", @@ -1842,7 +3341,7 @@ dependencies = [ "fontdb", "krilla", "resvg", - "skrifa", + "skrifa 0.42.1", "smallvec", "tiny-skia", "usvg", @@ -1860,6 +3359,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "ledger-cli" version = "0.1.0" @@ -1868,6 +3373,17 @@ dependencies = [ "toml 1.1.8+spec-1.1.0", ] +[[package]] +name = "ledger-config" +version = "0.1.0" +dependencies = [ + "chrono", + "ledger-core", + "rust_decimal", + "serde", + "toml 1.1.8+spec-1.1.0", +] + [[package]] name = "ledger-core" version = "0.1.0" @@ -1877,7 +3393,29 @@ dependencies = [ "rust_decimal", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", +] + +[[package]] +name = "ledger-gui" +version = "0.1.0" +dependencies = [ + "chrono", + "eframe", + "egui", + "egui-phosphor", + "egui_extras", + "egui_kittest", + "hayro 0.8.0", + "image", + "jiff", + "ledger-config", + "ledger-core", + "ledger-store", + "notify", + "rust_decimal", + "serde", + "serde_json", ] [[package]] @@ -1891,7 +3429,7 @@ dependencies = [ "rust_xlsxwriter", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", "typst", "typst-as-lib", "typst-layout", @@ -1905,6 +3443,7 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "ledger-config", "ledger-core", "ledger-import", "ledger-store", @@ -1914,7 +3453,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", "tokio", "toml 1.1.8+spec-1.1.0", "tower", @@ -1934,7 +3473,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tempfile", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -1949,6 +3488,16 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1961,7 +3510,10 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ + "bitflags 2.13.2", "libc", + "plain", + "redox_syscall 0.9.4", ] [[package]] @@ -1987,6 +3539,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2012,6 +3570,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + [[package]] name = "lock_api" version = "0.4.14" @@ -2057,6 +3621,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2073,6 +3646,18 @@ dependencies = [ "unicase", ] +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf 0.11.3", + "phf_shared 0.11.3", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2100,8 +3685,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", + "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2120,18 +3706,119 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.2", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -2164,6 +3851,339 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -2181,12 +4201,51 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "palette" version = "0.7.7" @@ -2220,6 +4279,12 @@ dependencies = [ "libm", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2238,7 +4303,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2271,7 +4336,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753" dependencies = [ - "bitflags", + "bitflags 2.13.2", "itoa", "memchr", "ryu", @@ -2296,17 +4361,37 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.8", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -2314,7 +4399,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", + "unicase", ] [[package]] @@ -2323,13 +4422,23 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "unicase", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -2355,18 +4464,55 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.10.1" @@ -2386,13 +4532,33 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + [[package]] name = "polycool" version = "0.4.0" @@ -2408,6 +4574,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postcard" version = "1.1.3" @@ -2450,6 +4625,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2474,6 +4655,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "proptest" version = "1.12.0" @@ -2482,7 +4669,7 @@ checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ "bit-set 0.11.1", "bit-vec 0.10.1", - "bitflags", + "bitflags 2.13.2", "chacha20", "core_detect", "num-traits", @@ -2532,6 +4719,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.42.0" @@ -2647,6 +4843,30 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "rayon" version = "1.12.0" @@ -2674,7 +4894,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", - "font-types", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.6", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types 0.12.6", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", ] [[package]] @@ -2683,7 +4934,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", ] [[package]] @@ -2693,7 +4953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -2745,6 +5005,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "resvg" version = "0.47.0" @@ -2794,7 +5060,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -2822,6 +5088,20 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c85cd47a33a4510b1424fe796498e174c6a9cf94e606460ef022a19f3e4ff85e" +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -2843,7 +5123,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2877,23 +5157,51 @@ dependencies = [ "zip 4.6.1", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -2920,7 +5228,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytemuck", "core_maths", "log", @@ -2973,12 +5281,24 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -3050,6 +5370,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3132,12 +5463,38 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -3160,7 +5517,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.39.2", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6" +dependencies = [ + "bytemuck", + "read-fonts 0.43.3", ] [[package]] @@ -3183,6 +5560,81 @@ name = "smallvec" version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -3191,7 +5643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3200,6 +5652,15 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "sse-stream" version = "0.2.6" @@ -3229,9 +5690,15 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strict-num" version = "0.1.1" @@ -3275,8 +5742,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" dependencies = [ "kurbo", - "rustc-hash", - "skrifa", + "rustc-hash 2.1.3", + "skrifa 0.42.1", "write-fonts", ] @@ -3345,7 +5812,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror", + "thiserror 2.0.21", "walkdir", "yaml-rust", ] @@ -3359,8 +5826,17 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", ] [[package]] @@ -3369,13 +5845,33 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3389,6 +5885,20 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.55" @@ -3475,7 +5985,7 @@ dependencies = [ "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3686,6 +6196,15 @@ dependencies = [ "syntect", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "typed-arena" version = "2.0.2" @@ -3698,6 +6217,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" @@ -3713,7 +6238,7 @@ dependencies = [ "arrayvec", "comemo", "ecow", - "rustc-hash", + "rustc-hash 2.1.3", "typst-eval", "typst-html", "typst-layout", @@ -3735,7 +6260,7 @@ dependencies = [ "comemo", "dirs", "ecow", - "thiserror", + "thiserror 2.0.21", "typst", ] @@ -3745,7 +6270,7 @@ version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcee505dac6702dd1c5e65aa2e94a6179d19ee09e2e5637d7313db91765dc4e0" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -3757,7 +6282,7 @@ dependencies = [ "comemo", "ecow", "indexmap", - "rustc-hash", + "rustc-hash 2.1.3", "stacker", "toml 0.8.23", "typst-library", @@ -3779,7 +6304,7 @@ dependencies = [ "comemo", "ecow", "palette", - "rustc-hash", + "rustc-hash 2.1.3", "time", "typst-assets", "typst-library", @@ -3811,7 +6336,7 @@ dependencies = [ "kurbo", "libm", "memchr", - "rustc-hash", + "rustc-hash 2.1.3", "rustybuzz", "smallvec", "ttf-parser", @@ -3835,7 +6360,7 @@ checksum = "12becd02464849f1507f3c4ef3f401a4d28d5938cc74b6acc6f1d703458bf393" dependencies = [ "arrayvec", "az", - "bitflags", + "bitflags 2.13.2", "bumpalo", "ciborium", "codex", @@ -3847,7 +6372,7 @@ dependencies = [ "fontdb", "glidesort", "hayagriva", - "hayro-syntax", + "hayro-syntax 0.7.2", "icu_properties", "image", "indexmap", @@ -3859,14 +6384,14 @@ dependencies = [ "moxcms", "palette", "percent-encoding", - "phf", + "phf 0.13.1", "png", "rayon", "regex", "regex-syntax", "roxmltree 0.21.1", "rust_decimal", - "rustc-hash", + "rustc-hash 2.1.3", "rustybuzz", "serde", "serde_json", @@ -3923,7 +6448,7 @@ dependencies = [ "infer", "krilla", "krilla-svg", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "smallvec", "typst-assets", @@ -3964,12 +6489,12 @@ dependencies = [ "comemo", "ecow", "flate2", - "hayro", + "hayro 0.7.1", "hayro-svg", "image", "indexmap", "itoa", - "rustc-hash", + "rustc-hash 2.1.3", "ryu", "ttf-parser", "typst-assets", @@ -3988,7 +6513,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92a20d79df5d10c7fb0b4f7c18e4b2671edfc21e5ed9ad9894e18cccefd1c70f" dependencies = [ "ecow", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "toml 0.8.23", "typst-timing", @@ -4021,7 +6546,7 @@ dependencies = [ "once_cell", "portable-atomic", "rayon", - "rustc-hash", + "rustc-hash 2.1.3", "semver", "siphasher", "smallvec", @@ -4029,6 +6554,17 @@ dependencies = [ "unicode-math-class", ] +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -4103,6 +6639,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -4148,6 +6690,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4221,6 +6769,7 @@ checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -4244,7 +6793,7 @@ dependencies = [ "peniko", "png", "smallvec", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -4261,7 +6810,38 @@ dependencies = [ "peniko", "png", "smallvec", - "thiserror", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd 0.4.1", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_common" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c2fbe53548bcd25c8ff42f75a93770a8611a16b37bee56d7ef7b44e36027f1" +dependencies = [ + "bytemuck", + "fearless_simd 0.7.0", + "guillotiere", + "log", + "peniko", + "png", + "smallvec", + "thiserror 2.0.21", ] [[package]] @@ -4271,12 +6851,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" dependencies = [ "bytemuck", - "glifo", + "glifo 0.1.1", "hashbrown 0.17.1", "png", "vello_common 0.0.8", ] +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo 0.2.0", + "hashbrown 0.17.1", + "vello_common 0.1.0", +] + +[[package]] +name = "vello_cpu" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1fec424fbba6acf6cbc78bb526447eb77590d95f40419d598870fe37535212" +dependencies = [ + "bytemuck", + "glifo 0.4.0", + "hashbrown 0.17.1", + "png", + "vello_common 0.3.0", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4331,6 +6936,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -4406,7 +7022,162 @@ version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" dependencies = [ - "bitflags", + "bitflags 2.13.2", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", ] [[package]] @@ -4415,13 +7186,217 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.10.0", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", ] [[package]] @@ -4437,6 +7412,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4465,6 +7451,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4483,6 +7479,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4492,6 +7515,195 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "0.7.15" @@ -4522,11 +7734,11 @@ version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" dependencies = [ - "font-types", + "font-types 0.11.3", "indexmap", "kurbo", "log", - "read-fonts", + "read-fonts 0.39.2", ] [[package]] @@ -4541,6 +7753,69 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "xmlwriter" version = "0.1.0" @@ -4585,6 +7860,112 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.7", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow 1.0.4", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.62" @@ -4777,3 +8158,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.7", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.7", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 2cd08c1..a2eb360 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli"] +members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui"] [workspace.package] edition = "2024" diff --git a/README.md b/README.md index dee91ee..c4db022 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,21 @@ through MCP. Self-hosted, no rent. append-only, hash-chained JSONL journal; SQLite is a disposable projection rebuilt from it; receipts live in a content-addressed blob store. Drafts are durable, corrupt projections self-heal. -- **`ledger-mcp`** — the AI-first interface. 27 MCP tools over stdio or +- **`ledger-mcp`** — the AI-first interface. 64 MCP tools over stdio or Streamable HTTP: draft → preview → post workflow, server-side receipt policy (>$25), anti-splitting detection, asset management, digest for human review, plus read-only resources for dashboards. - **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs and chart-of-accounts CSVs; export IIF journal entries (for QuickBooks Desktop) and XLSX reports. +- **`ledger-config`** — the company settings in a books dir's + `wanderbooks.toml`. +- **`ledger-gui`** — the read-only desktop view for owners and their + accountant (egui). Answers first (cash, runway, profit, what needs + attention), then every number drills down to its lines, its entry, the + rendered receipt, and the journal history. Follows the journal live: + a change the agent makes appears in milliseconds. `--demo` opens a + realistic demo company. ## Verification @@ -51,6 +59,7 @@ See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and cargo test # whole suite cargo run -p ledger-cli -- init ~/books/acme # scaffold a books dir: mise.toml + AGENT.md cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio +cargo run -p ledger-gui -- --demo # the desktop view, on a demo company ``` Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`, `mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md. @@ -60,8 +69,10 @@ Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`, ``` crates/ledger-core/ # engine: money, accounts, journal, ledger, reports crates/ledger-store/ # journal + SQLite projection + blob store -crates/ledger-mcp/ # MCP server: 27 tools, resources, receipt policy +crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy crates/ledger-import/ # QBO CSV import, IIF/XLSX export +crates/ledger-config/ # wanderbooks.toml company settings +crates/ledger-gui/ # read-only live desktop view (egui) docs/ # requirements, architecture, oracle, parity, glossary plan/ # STATUS.md, roadmap.md AGENTS.md # operating manual for agents working here diff --git a/crates/ledger-config/Cargo.toml b/crates/ledger-config/Cargo.toml new file mode 100644 index 0000000..c459de6 --- /dev/null +++ b/crates/ledger-config/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ledger-config" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Company settings for wanderbooks: the books dir's wanderbooks.toml." + +[dependencies] +ledger-core = { path = "../ledger-core" } +chrono = "0.4" +rust_decimal = "1" +serde = { version = "1", features = ["derive"] } +toml = { version = "1", features = ["preserve_order"] } + +[lints] +workspace = true diff --git a/crates/ledger-mcp/src/company.rs b/crates/ledger-config/src/lib.rs similarity index 88% rename from crates/ledger-mcp/src/company.rs rename to crates/ledger-config/src/lib.rs index edce28f..1817b85 100644 --- a/crates/ledger-mcp/src/company.rs +++ b/crates/ledger-config/src/lib.rs @@ -35,6 +35,9 @@ pub struct Company { /// `[books] close_sweep`: equity accounts (draws, dividends, /// distributions) a close zeroes into `close_into`. pub close_sweep: Vec, + /// Every simple `[company]` setting (name, EIN, formation date, ...), + /// in file order, as written: the full profile for display. + pub profile: Vec<(String, String)>, } /// One member of a multi-member LLC. @@ -61,6 +64,7 @@ impl Default for Company { tax_form: None, close_into: None, close_sweep: Vec::new(), + profile: Vec::new(), } } } @@ -89,6 +93,10 @@ pub struct BooksSettings { pub company: Company, /// `[books] receipt_threshold`, validated as a decimal string. pub receipt_threshold: Option, + /// `[books] cash_accounts`: account codes that hold cash (bank, + /// savings, cash on hand), for cash reports and dashboards. Empty: + /// callers decide (e.g. by account name). + pub cash_accounts: Vec, } #[derive(Deserialize)] @@ -124,6 +132,8 @@ struct BooksSection { close_into: Option, #[serde(default)] close_sweep: Vec, + #[serde(default)] + cash_accounts: Vec, } /// The return a federal tax classification files: "partnership" → 1065, @@ -238,9 +248,27 @@ pub fn parse(text: &str) -> Result { } None => None, }; + let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?; + if let Some(toml::Value::Table(c)) = table.get("company") { + company.profile = c + .iter() + .filter_map(|(k, v)| { + let s = match v { + toml::Value::String(s) => s.clone(), + toml::Value::Integer(i) => i.to_string(), + toml::Value::Float(f) => f.to_string(), + toml::Value::Boolean(b) => b.to_string(), + toml::Value::Datetime(d) => d.to_string(), + _ => return None, + }; + Some((k.clone(), s)) + }) + .collect(); + } Ok(BooksSettings { company, receipt_threshold, + cash_accounts: raw.books.cash_accounts, }) } @@ -306,6 +334,23 @@ mod tests { ); } + #[test] + fn keeps_the_whole_profile_in_order() { + let s = parse( + "[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\n", + ) + .unwrap(); + assert_eq!( + s.company.profile, + [ + ("name".to_string(), "Acme LLC".to_string()), + ("ein".to_string(), "12-3456789".to_string()), + ("formed".to_string(), "2026-10-04".to_string()), + ] + ); + assert_eq!(s.cash_accounts, ["1000", "1010"]); + } + #[test] fn reads_members_and_basis() { let s = parse( diff --git a/crates/ledger-core/src/analysis.rs b/crates/ledger-core/src/analysis.rs new file mode 100644 index 0000000..ba323b5 --- /dev/null +++ b/crates/ledger-core/src/analysis.rs @@ -0,0 +1,245 @@ +//! Time series over the books, for dashboards and interval-column reports. +//! +//! Statements answer "what was true for this range"; these answer "how did +//! it change": a range split into months, quarters, or years; balances of a +//! set of accounts on a series of dates; profit and loss per interval. They +//! are built from the same posted entries and statements as everything +//! else, so a number on a chart always ties to a number on a statement. + +use crate::reports::profit_and_loss_for_period; +use crate::{AccountId, AccountType, Ledger, LedgerError, Side}; +use chrono::{Datelike, Days, Months, NaiveDate}; +use rust_decimal::Decimal; +use std::collections::HashSet; + +/// How to split a date range into columns. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Interval { + /// Calendar months. + Month, + /// Calendar quarters. + Quarter, + /// Calendar years. + Year, +} + +impl Interval { + fn months(self) -> u32 { + match self { + Interval::Month => 1, + Interval::Quarter => 3, + Interval::Year => 12, + } + } +} + +/// The first day of the calendar interval containing `d`. +pub fn interval_start(d: NaiveDate, interval: Interval) -> NaiveDate { + let month0 = (d.month0() / interval.months()) * interval.months(); + NaiveDate::from_ymd_opt(d.year(), month0 + 1, 1).expect("valid") +} + +/// `[start, end]` split on calendar interval boundaries, first and last +/// pieces clipped to the range. Empty when `end < start`. +pub fn intervals( + start: NaiveDate, + end: NaiveDate, + interval: Interval, +) -> Vec<(NaiveDate, NaiveDate)> { + let mut out = Vec::new(); + let mut from = interval_start(start, interval); + while from <= end { + let next = from + Months::new(interval.months()); + let to = (next - Days::new(1)).min(end); + out.push((from.max(start), to)); + from = next; + } + out +} + +/// The combined normal-side balance of `accounts` at the end of each date +/// in `dates` (which must be ascending). Assets and expenses count debits +/// as positive; liabilities, equity, and revenue count credits. Mixing +/// types is allowed but rarely meaningful. +pub fn balance_series( + ledger: &Ledger, + accounts: &[AccountId], + dates: &[NaiveDate], +) -> Vec { + let set: HashSet = accounts.iter().copied().collect(); + let mut postings: Vec<(NaiveDate, Decimal)> = Vec::new(); + for entry in ledger.posted_entries() { + for line in entry.lines() { + if !set.contains(&line.account) { + continue; + } + let Some(account) = ledger.chart().get(line.account) else { + continue; + }; + let signed = if line.side == account.account_type.normal_side() { + line.amount.amount + } else { + -line.amount.amount + }; + postings.push((entry.date(), signed)); + } + } + postings.sort_by_key(|(d, _)| *d); + let mut out = Vec::with_capacity(dates.len()); + let mut running = Decimal::ZERO; + let mut i = 0; + for date in dates { + while i < postings.len() && postings[i].0 <= *date { + running += postings[i].1; + i += 1; + } + out.push(running); + } + out +} + +/// Every date `accounts` moved, with the combined balance at the end of +/// that day: the exact shape of a balance-over-time chart. +pub fn balance_history(ledger: &Ledger, accounts: &[AccountId]) -> Vec<(NaiveDate, Decimal)> { + let set: HashSet = accounts.iter().copied().collect(); + let mut dates: Vec = ledger + .posted_entries() + .filter(|e| e.lines().iter().any(|l| set.contains(&l.account))) + .map(|e| e.date()) + .collect(); + dates.sort(); + dates.dedup(); + let balances = balance_series(ledger, accounts, &dates); + dates.into_iter().zip(balances).collect() +} + +/// One interval's profit and loss. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IntervalPnl { + /// First day. + pub start: NaiveDate, + /// Last day. + pub end: NaiveDate, + /// Total revenue. + pub revenue: Decimal, + /// Total expenses. + pub expenses: Decimal, +} + +impl IntervalPnl { + /// Revenue minus expenses. + pub fn net(&self) -> Decimal { + self.revenue - self.expenses + } +} + +/// Profit and loss per interval across `[start, end]` (closing entries +/// excluded, as on every period statement). +pub fn pnl_by_interval( + ledger: &Ledger, + start: NaiveDate, + end: NaiveDate, + interval: Interval, +) -> Result, LedgerError> { + intervals(start, end, interval) + .into_iter() + .map(|(s, e)| { + let pl = profit_and_loss_for_period(ledger, s, e, None)?; + Ok(IntervalPnl { + start: s, + end: e, + revenue: pl.total_revenue, + expenses: pl.total_expenses, + }) + }) + .collect() +} + +/// Money in and out of `accounts` per interval: (inflows, outflows), each +/// positive. Each entry counts by its net debit (in) or credit (out) to the +/// accounts together: for cash, deposits and payments; a transfer between +/// two of them (checking to savings) is neither; with a credit card in the +/// set, a charge is money out and paying the card moves nothing. +pub fn flows_by_interval( + ledger: &Ledger, + accounts: &[AccountId], + start: NaiveDate, + end: NaiveDate, + interval: Interval, +) -> Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> { + let set: HashSet = accounts.iter().copied().collect(); + let mut out: Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> = intervals(start, end, interval) + .into_iter() + .map(|(s, e)| (s, e, Decimal::ZERO, Decimal::ZERO)) + .collect(); + for entry in ledger.posted_entries() { + let d = entry.date(); + let Some(slot) = out.iter_mut().find(|(s, e, _, _)| *s <= d && d <= *e) else { + continue; + }; + // Debits in, credits out, for every account in the set: cash + // received is a debit to cash, and a charge on a card in the set is + // a credit to the card. + let mut net = Decimal::ZERO; + for line in entry.lines().iter().filter(|l| set.contains(&l.account)) { + match line.side { + Side::Debit => net += line.amount.amount, + Side::Credit => net -= line.amount.amount, + } + } + if net > Decimal::ZERO { + slot.2 += net; + } else { + slot.3 -= net; + } + } + out +} + +/// Accounts of the given types, in chart order. +pub fn accounts_of_type(ledger: &Ledger, types: &[AccountType]) -> Vec { + ledger + .chart() + .all() + .filter(|a| types.contains(&a.account_type)) + .map(|a| a.id) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() + } + + #[test] + fn intervals_clip_to_the_range() { + assert_eq!( + intervals(d(2026, 1, 15), d(2026, 3, 10), Interval::Month), + [ + (d(2026, 1, 15), d(2026, 1, 31)), + (d(2026, 2, 1), d(2026, 2, 28)), + (d(2026, 3, 1), d(2026, 3, 10)), + ] + ); + assert_eq!( + intervals(d(2026, 2, 1), d(2026, 12, 31), Interval::Quarter), + [ + (d(2026, 2, 1), d(2026, 3, 31)), + (d(2026, 4, 1), d(2026, 6, 30)), + (d(2026, 7, 1), d(2026, 9, 30)), + (d(2026, 10, 1), d(2026, 12, 31)), + ] + ); + assert_eq!( + intervals(d(2025, 6, 1), d(2026, 2, 1), Interval::Year), + [ + (d(2025, 6, 1), d(2025, 12, 31)), + (d(2026, 1, 1), d(2026, 2, 1)), + ] + ); + assert!(intervals(d(2026, 2, 1), d(2026, 1, 1), Interval::Month).is_empty()); + } +} diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index eb7665a..92c2907 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -17,6 +17,7 @@ //! (`docs/oracle.md`) possible. pub mod account; +pub mod analysis; pub mod arap; pub mod assets; pub mod classes; diff --git a/crates/ledger-core/tests/analysis.rs b/crates/ledger-core/tests/analysis.rs new file mode 100644 index 0000000..ed7efcd --- /dev/null +++ b/crates/ledger-core/tests/analysis.rs @@ -0,0 +1,81 @@ +//! Time series. Numbers computed by hand. +//! +//! Cash 1000 and card 2100. Jan 5: owner puts in 5,000. Jan 20: 300 of +//! software on the card. Feb 3: 1,200 of sales into cash. Feb 10: pay the +//! card 300. Mar 15: 800 rent from cash. + +use chrono::NaiveDate; +use ledger_core::analysis::{ + Interval, balance_history, balance_series, flows_by_interval, pnl_by_interval, +}; +use ledger_core::{AccountType, Currency, Ledger, Money, Side}; +use rust_decimal::Decimal; + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} + +fn dec(n: i64) -> Decimal { + Decimal::from(n) +} + +#[test] +fn balances_pnl_and_flows_over_time() { + let mut l = Ledger::new(Currency::USD); + let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap(); + let cash = a("1000", AccountType::Asset); + let card = a("2100", AccountType::Liability); + let equity = a("3000", AccountType::Equity); + let sales = a("4000", AccountType::Revenue); + let software = a("6300", AccountType::Expense); + let rent = a("6600", AccountType::Expense); + for (date, dr, cr, amt) in [ + (d(1, 5), cash, equity, 5000), + (d(1, 20), software, card, 300), + (d(2, 3), cash, sales, 1200), + (d(2, 10), card, cash, 300), + (d(3, 15), rent, cash, 800), + ] { + let e = l.create_entry(date, "x"); + let m = Money::new(dec(amt), Currency::USD); + l.add_line(e, dr, Side::Debit, m).unwrap(); + l.add_line(e, cr, Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + } + assert_eq!( + balance_series(&l, &[cash], &[d(1, 1), d(1, 31), d(2, 28), d(3, 31)]), + [dec(0), dec(5000), dec(5900), dec(5100)] + ); + // The card is a liability: owed 300, then paid off. + assert_eq!( + balance_series(&l, &[card], &[d(1, 31), d(2, 28)]), + [dec(300), dec(0)] + ); + assert_eq!( + balance_history(&l, &[cash]), + [ + (d(1, 5), dec(5000)), + (d(2, 3), dec(6200)), + (d(2, 10), dec(5900)), + (d(3, 15), dec(5100)) + ] + ); + let pnl = pnl_by_interval(&l, d(1, 1), d(3, 31), Interval::Month).unwrap(); + let nets: Vec = pnl.iter().map(|p| p.net()).collect(); + assert_eq!(nets, [dec(-300), dec(1200), dec(-800)]); + let flows = flows_by_interval(&l, &[cash], d(1, 1), d(3, 31), Interval::Month); + let io: Vec<(Decimal, Decimal)> = flows.iter().map(|f| (f.2, f.3)).collect(); + assert_eq!( + io, + [ + (dec(5000), dec(0)), + (dec(1200), dec(300)), + (dec(0), dec(800)) + ] + ); + // Cash and card together: a card charge is money out when it's made, + // and paying the card moves nothing out of the pair. + let both = flows_by_interval(&l, &[cash, card], d(1, 1), d(2, 28), Interval::Month); + let io: Vec<(Decimal, Decimal)> = both.iter().map(|f| (f.2, f.3)).collect(); + assert_eq!(io, [(dec(5000), dec(300)), (dec(1200), dec(0))]); +} diff --git a/crates/ledger-gui/Cargo.toml b/crates/ledger-gui/Cargo.toml new file mode 100644 index 0000000..441da96 --- /dev/null +++ b/crates/ledger-gui/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "ledger-gui" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Read-only, live desktop view of wanderbooks books." + +[[bin]] +name = "ledger-gui" +path = "src/main.rs" + +[dependencies] +ledger-core = { path = "../ledger-core" } +ledger-store = { path = "../ledger-store" } +ledger-config = { path = "../ledger-config" } +eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] } +egui = "0.36" +egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] } +egui-phosphor = "0.14" +hayro = { version = "0.8", features = ["embed-fonts"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +notify = "8" +jiff = "0.2" +chrono = "0.4" +rust_decimal = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +chrono = "0.4" +egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] } + +[lints] +workspace = true diff --git a/crates/ledger-gui/assets/fonts/Inter-LICENSE.txt b/crates/ledger-gui/assets/fonts/Inter-LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/crates/ledger-gui/assets/fonts/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/ledger-gui/assets/fonts/InterTab-Bold.ttf b/crates/ledger-gui/assets/fonts/InterTab-Bold.ttf new file mode 100644 index 0000000..844aeb0 Binary files /dev/null and b/crates/ledger-gui/assets/fonts/InterTab-Bold.ttf differ diff --git a/crates/ledger-gui/assets/fonts/InterTab-Regular.ttf b/crates/ledger-gui/assets/fonts/InterTab-Regular.ttf new file mode 100644 index 0000000..40a1209 Binary files /dev/null and b/crates/ledger-gui/assets/fonts/InterTab-Regular.ttf differ diff --git a/crates/ledger-gui/assets/fonts/InterTab-SemiBold.ttf b/crates/ledger-gui/assets/fonts/InterTab-SemiBold.ttf new file mode 100644 index 0000000..6863b2f Binary files /dev/null and b/crates/ledger-gui/assets/fonts/InterTab-SemiBold.ttf differ diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs new file mode 100644 index 0000000..90e260a --- /dev/null +++ b/crates/ledger-gui/src/app.rs @@ -0,0 +1,442 @@ +//! The window: sidebar, top bar, and the current page. + +use crate::books::Books; +use crate::data::{Loader, Source, Update}; +use crate::nav::{Nav, Route, Section}; +use crate::pages; +use crate::receipts::Receipts; +use crate::theme::{self, pal}; +use egui::{ + Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2, +}; +use egui_phosphor::regular as icon; +use std::sync::Arc; + +/// Per-page state that survives navigation. +#[derive(Default)] +pub struct PageState { + pub transactions: pages::transactions::State, + pub reports: pages::reports::State, + pub accounts: pages::accounts::State, + pub documents: pages::documents::State, + pub journal: pages::journal::State, +} + +/// What a page can ask of the shell. +pub struct Shell<'a> { + pub receipts: &'a mut Receipts, + pub state: &'a mut PageState, + pub go: Option, + /// Seconds since the last live update arrived (for "new" highlights). + pub since_update: f64, +} + +impl Shell<'_> { + pub fn go(&mut self, r: Route) { + self.go = Some(r); + } +} + +pub struct App { + loader: Option, + books: Option>, + error: Option, + nav: Nav, + receipts: Receipts, + dark: bool, + state: PageState, + palette: crate::palette::Palette, + updated_at: f64, +} + +impl App { + /// The live app over a books directory. + pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self { + let dark = cc + .storage + .and_then(|s| s.get_string("dark")) + .map(|v| v == "true") + .unwrap_or(cc.egui_ctx.global_style().visuals.dark_mode); + let mut app = Self::bare(&cc.egui_ctx, dark); + app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone())); + app + } + + /// An app showing fixed books (tests and screenshots). + pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self { + let mut app = Self::bare(ctx, dark); + app.books = Some(Arc::new(books)); + app.nav = Nav::new(start); + app + } + + fn bare(ctx: &egui::Context, dark: bool) -> Self { + theme::install_fonts(ctx); + theme::apply(ctx, dark); + egui_extras::install_image_loaders(ctx); + Self { + loader: None, + books: None, + error: None, + nav: Nav::new(Route::Overview), + receipts: Receipts::new(ctx), + dark, + state: PageState::default(), + palette: Default::default(), + updated_at: -1e9, + } + } + + /// Navigate (tests). + pub fn goto(&mut self, r: Route) { + self.nav.go(r); + } + + /// Block until every receipt on screen has rendered (screenshots). + pub fn settle(&mut self, ctx: &egui::Context) { + self.receipts.settle(ctx); + } + + /// Page state (tests). + pub fn state_mut(&mut self) -> &mut PageState { + &mut self.state + } + + fn handle_keys(&mut self, ui: &Ui) { + let (back, fwd, palette) = ui.input(|i| { + ( + (i.modifiers.alt && i.key_pressed(Key::ArrowLeft)) + || i.pointer.button_pressed(egui::PointerButton::Extra1), + (i.modifiers.alt && i.key_pressed(Key::ArrowRight)) + || i.pointer.button_pressed(egui::PointerButton::Extra2), + i.modifiers.command && i.key_pressed(Key::K), + ) + }); + if back { + self.nav.back(); + } + if fwd { + self.nav.forward(); + } + if palette { + self.palette.open(); + } + } + + fn sidebar(&mut self, ui: &mut Ui) { + let p = pal(ui.ctx()); + let books = self.books.clone(); + egui::Panel::left("sidebar") + .exact_size(224.0) + .resizable(false) + .frame( + Frame::new() + .fill(p.sidebar) + .inner_margin(Margin::symmetric(12, 16)), + ) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(8), p.accent); + let initial = books + .as_ref() + .and_then(|b| b.company_name().chars().next()) + .unwrap_or('W'); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + initial, + theme::bold(15.0), + Color32::WHITE, + ); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + let name = books + .as_ref() + .map(|b| b.company_name().to_string()) + .unwrap_or_else(|| "wanderbooks".into()); + ui.add( + egui::Label::new(RichText::new(name).font(theme::semibold(13.5))) + .truncate(), + ); + ui.label( + RichText::new("Books") + .font(theme::regular(11.5)) + .color(p.muted), + ); + }); + }); + ui.add_space(18.0); + let current = self.nav.current.section(); + let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0); + for s in Section::ALL { + let on = s == current; + let (rect, resp) = + ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + if on { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.surface); + ui.painter().rect_stroke( + rect, + CornerRadius::same(8), + Stroke::new(1.0, p.border), + egui::StrokeKind::Inside, + ); + } else if resp.hovered() { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.hover); + } + let color = if on { p.text } else { p.muted }; + ui.painter().text( + rect.left_center() + vec2(10.0, 0.0), + egui::Align2::LEFT_CENTER, + s.icon(), + egui::FontId::proportional(16.0), + if on { p.accent } else { p.muted }, + ); + ui.painter().text( + rect.left_center() + vec2(36.0, 0.0), + egui::Align2::LEFT_CENTER, + s.title(), + if on { + theme::semibold(13.5) + } else { + theme::regular(13.5) + }, + color, + ); + if s == Section::Overview && attention > 0 { + let badge = egui::Rect::from_center_size( + rect.right_center() - vec2(18.0, 0.0), + vec2(22.0, 18.0), + ); + ui.painter() + .rect_filled(badge, CornerRadius::same(9), p.warning_soft); + ui.painter().text( + badge.center(), + egui::Align2::CENTER_CENTER, + attention.to_string(), + theme::semibold(11.0), + p.warning, + ); + } + if resp.clicked() { + self.nav.go(s.home()); + } + } + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + let label = if self.dark { + format!("{} Light mode", icon::SUN) + } else { + format!("{} Dark mode", icon::MOON) + }; + if ui + .add( + egui::Button::new( + RichText::new(label) + .font(theme::regular(12.5)) + .color(p.muted), + ) + .frame(false), + ) + .clicked() + { + self.dark = !self.dark; + theme::apply(ui.ctx(), self.dark); + } + ui.add_space(6.0); + if let Some(b) = &books { + live_status( + ui, + b, + ui.input(|i| i.time) - self.updated_at, + self.loader.is_some(), + ); + } + }); + }); + } + + fn topbar(&mut self, ui: &mut Ui) { + let p = pal(ui.ctx()); + egui::Panel::top("topbar") + .exact_size(52.0) + .frame( + Frame::new() + .fill(p.bg) + .inner_margin(Margin::symmetric(20, 10)) + .stroke(Stroke::new(1.0, p.border)), + ) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + let nav_btn = |ui: &mut Ui, glyph: &str, enabled: bool, tip: &str| { + ui.add_enabled( + enabled, + egui::Button::new(RichText::new(glyph).size(16.0)) + .frame(false) + .min_size(vec2(28.0, 28.0)), + ) + .on_hover_text(tip) + .clicked() + }; + if nav_btn(ui, icon::ARROW_LEFT, self.nav.can_back(), "Back (Alt+←)") { + self.nav.back(); + } + if nav_btn( + ui, + icon::ARROW_RIGHT, + self.nav.can_forward(), + "Forward (Alt+→)", + ) { + self.nav.forward(); + } + ui.add_space(6.0); + let crumb = self + .books + .as_ref() + .map(|b| crate::palette::describe(b, &self.nav.current)) + .unwrap_or_default(); + ui.label( + RichText::new(self.nav.current.section().title()) + .font(theme::regular(13.5)) + .color(p.muted), + ); + if !crumb.is_empty() { + ui.label(RichText::new(icon::CARET_RIGHT).color(p.faint)); + ui.add( + egui::Label::new(RichText::new(crumb).font(theme::semibold(13.5))) + .truncate(), + ); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let search = egui::Button::new( + RichText::new(format!( + "{} Search everything… Ctrl K", + icon::MAGNIFYING_GLASS + )) + .font(theme::regular(13.0)) + .color(p.faint), + ) + .fill(p.surface) + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(8)) + .min_size(vec2(260.0, 30.0)); + if ui.add(search).clicked() { + self.palette.open(); + } + if let Some(b) = &self.books { + ui.label( + RichText::new(format!("Today {}", crate::fmt::date(b.today))) + .font(theme::regular(12.5)) + .color(p.muted), + ); + } + }); + }); + }); + } +} + +fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { + let p = pal(ui.ctx()); + Frame::new() + .fill(p.surface) + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::same(10)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(8.0, 8.0), Sense::hover()); + let pulse = (since_update < 2.0) as u8 as f32; + let color = if live { p.positive } else { p.faint }; + ui.painter().circle_filled(r.center(), 4.0 + pulse, color); + ui.label( + RichText::new(if live { "Live" } else { "Snapshot" }) + .font(theme::semibold(12.0)), + ); + ui.label( + RichText::new(format!("· event {}", b.seq())) + .font(theme::regular(12.0)) + .color(p.muted), + ); + }); + ui.label( + RichText::new(format!("{} Journal chain verified", icon::SEAL_CHECK)) + .font(theme::regular(11.5)) + .color(p.muted), + ) + .on_hover_text(format!( + "Every journal event's hash chains to the one before it.\nHead: {}\nData: {}", + b.head, + b.data_dir.display() + )); + }); +} + +impl eframe::App for App { + fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + if let Some(loader) = &self.loader { + match loader.poll() { + Some(Update::Books(b)) => { + if self.books.is_some() { + self.updated_at = ui.input(|i| i.time); + } + self.books = Some(b); + self.error = None; + } + Some(Update::Error(e)) => self.error = Some(e), + None => {} + } + } + self.receipts.pump(ui.ctx()); + self.handle_keys(ui); + self.sidebar(ui); + self.topbar(ui); + let p = pal(ui.ctx()); + let since_update = ui.input(|i| i.time) - self.updated_at; + if since_update < 6.0 { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(250)); + } + egui::CentralPanel::no_frame() + .frame(Frame::new().fill(p.bg)) + .show(ui, |ui| { + if let Some(err) = &self.error { + Frame::new() + .fill(p.negative_soft) + .inner_margin(Margin::symmetric(20, 8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label( + RichText::new(format!("{} {err}", icon::WARNING)).color(p.negative), + ); + }); + } + let Some(books) = self.books.clone() else { + ui.centered_and_justified(|ui| { + ui.label(RichText::new("Opening the books…").color(p.muted)); + }); + return; + }; + let mut shell = Shell { + receipts: &mut self.receipts, + state: &mut self.state, + go: None, + since_update, + }; + let route = self.nav.current.clone(); + pages::show(ui, &books, &route, &mut shell); + if let Some(to) = shell.go.take() { + self.nav.go(to); + } + if let Some(to) = self.palette.show(ui.ctx(), &books) { + self.nav.go(to); + } + }); + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + storage.set_string("dark", self.dark.to_string()); + } +} diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs new file mode 100644 index 0000000..dda53e2 --- /dev/null +++ b/crates/ledger-gui/src/attention.rs @@ -0,0 +1,265 @@ +//! What the books need: drafts left unposted, missing receipts, schedules +//! and recurring entries due, open past periods, uncategorized tax lines, +//! 1099 vendors without a W-9, overdue bills and invoices, money owed to +//! owners. Each item says what's wrong, where to look, and the prompt that +//! asks the agent to fix it (the GUI never changes the books itself). + +use crate::books::{Attention, Books, Severity}; +use crate::fmt; +use crate::nav::Route; +use chrono::Datelike; +use ledger_core::reports::profit_and_loss_for_period; +use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report}; +use rust_decimal::Decimal; + +pub fn find(b: &Books) -> Vec { + let mut out = Vec::new(); + let today = b.today; + + for e in b.entries.iter().filter(|e| e.status == EntryStatus::Draft) { + out.push(Attention { + severity: Severity::Todo, + title: format!("Draft not posted: {}", e.description), + detail: format!( + "#{} · {} · {}. Drafts appear in no report until posted.", + e.id, + fmt::date(e.date), + fmt::dollars(e.amount) + ), + target: Some(Route::Entry(e.id)), + prompt: format!( + "In wanderbooks, review draft entry {} (\"{}\", {}): attach its receipt if it needs one, preview it, and post it if it's right.", + e.id, + e.description, + fmt::dollars(e.amount) + ), + }); + } + + let missing: Vec<_> = b + .entries + .iter() + .filter(|e| e.status == EntryStatus::Posted && e.missing_receipt) + .collect(); + if missing.len() > 3 { + let total: Decimal = missing.iter().map(|e| e.amount).sum(); + let list: Vec = missing + .iter() + .map(|e| format!("{} ({}, {})", e.id, e.description, e.date)) + .collect(); + out.push(Attention { + severity: Severity::Problem, + title: format!("{} without receipts", fmt::count(missing.len(), "expense", "expenses")), + detail: format!( + "{} of spending over the {} threshold has no receipt attached.", + fmt::dollars(total), + fmt::dollars(b.receipt_threshold) + ), + target: Some(Route::Transactions), + prompt: format!( + "In wanderbooks, these entries have no receipt: {}. Help me find each receipt and attach it with books.attach_receipt.", + list.join("; ") + ), + }); + } + for e in missing.iter().filter(|_| missing.len() <= 3) { + out.push(Attention { + severity: Severity::Problem, + title: format!("No receipt: {}", e.description), + detail: format!( + "#{} · {} · {} is over the {} receipt threshold.", + e.id, + fmt::date(e.date), + fmt::dollars(e.amount), + fmt::dollars(b.receipt_threshold) + ), + target: Some(Route::Entry(e.id)), + prompt: format!( + "In wanderbooks, entry {} (\"{}\", {} on {}) has no receipt. Find it and attach it with books.attach_receipt.", + e.id, + e.description, + fmt::dollars(e.amount), + e.date + ), + }); + } + + for r in b.ledger.recurring_entries() { + let due = r.due(today); + if !due.is_empty() { + out.push(Attention { + severity: Severity::Todo, + title: format!("Recurring \"{}\" is due", r.description), + detail: format!( + "{} since {}.", + fmt::count(due.len(), "occurrence", "occurrences"), + fmt::date(due[0]) + ), + target: Some(Route::Company), + prompt: format!( + "In wanderbooks, run books.run_recurring through {today}, then attach each draft's receipt and post them." + ), + }); + } + } + + for s in b.ledger.schedules() { + let due = s.due(today); + if !due.is_empty() { + let total: Decimal = due.iter().map(|(_, a)| *a).sum(); + out.push(Attention { + severity: Severity::Todo, + title: format!("Amortization due: {}", s.description), + detail: format!( + "{} totaling {} not yet posted.", + fmt::count(due.len(), "month", "months"), + fmt::dollars(total) + ), + target: Some(Route::Company), + prompt: format!( + "In wanderbooks, preview books.run_schedules through {today}, then post it if it's right." + ), + }); + } + } + + for p in b.ledger.list_periods() { + if p.status == PeriodStatus::Open && p.end < today { + out.push(Attention { + severity: Severity::Todo, + title: format!("Period {} ended but is open", p.name), + detail: format!( + "{} – {}. Closing locks it and moves its earnings to equity.", + fmt::date(p.start), + fmt::date(p.end) + ), + target: Some(Route::Company), + prompt: format!( + "In wanderbooks, review period {} and, if everything for it is recorded and reconciled, preview and close it.", + p.name + ), + }); + } + } + + let (fy_start, _) = b.fiscal_ytd(); + if let Ok(pl) = profit_and_loss_for_period(&b.ledger, fy_start, today, None) { + let uncategorized: Vec = pl + .revenue + .iter() + .chain(&pl.expenses) + .filter(|l| { + b.ledger + .chart() + .find_by_code(&l.code) + .is_some_and(|a| a.tax_category.is_none()) + }) + .map(|l| format!("{} {}", l.code, l.name)) + .collect(); + if !uncategorized.is_empty() { + out.push(Attention { + severity: Severity::Info, + title: format!( + "{} without a tax category", + fmt::count(uncategorized.len(), "account", "accounts") + ), + detail: format!( + "{}. The tax summary lists them as uncategorized.", + uncategorized.join(", ") + ), + target: Some(Route::Accounts), + prompt: format!( + "In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.", + uncategorized.join(", ") + ), + }); + } + } + + let mut years = vec![today.year()]; + if today.month() == 1 { + years.insert(0, today.year() - 1); + } + for year in years { + let r = vendor_1099_report(&b.ledger, year, None); + for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) { + out.push(Attention { + severity: Severity::Problem, + title: format!("{} needs a W-9", v.name), + detail: format!( + "Paid {} in {year}, over the {} 1099 threshold.", + fmt::dollars(v.paid), + fmt::dollars(r.threshold) + ), + target: Some(Route::Company), + prompt: format!( + "Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.", + v.name + ), + }); + } + } + + let overdue = |rows: Vec| -> (usize, Decimal) { + let late: Vec = rows + .iter() + .map(|r| r.days_30 + r.days_60 + r.days_90 + r.days_90_plus) + .filter(|d| !d.is_zero()) + .collect(); + (late.len(), late.iter().sum()) + }; + let (n, total) = overdue(b.ledger.ap_aging(today)); + if n > 0 { + out.push(Attention { + severity: Severity::Problem, + title: format!("{} overdue to pay", fmt::dollars(total)), + detail: format!( + "{} with bills past due.", + fmt::count(n, "vendor", "vendors") + ), + target: Some(Route::Reports(crate::nav::ReportKind::ApAging)), + prompt: "In wanderbooks, show me the A/P aging and which bills are past due.".into(), + }); + } + let (n, total) = overdue(b.ledger.ar_aging(today)); + if n > 0 { + out.push(Attention { + severity: Severity::Problem, + title: format!("{} overdue to collect", fmt::dollars(total)), + detail: format!( + "{} with invoices past due.", + fmt::count(n, "customer", "customers") + ), + target: Some(Route::Reports(crate::nav::ReportKind::ArAging)), + prompt: + "In wanderbooks, show me the A/R aging and draft reminders for past-due invoices." + .into(), + }); + } + + for a in b.ledger.chart().all().filter(|a| { + a.account_type == AccountType::Liability && { + let n = a.name.to_lowercase(); + n.contains("due to") || n.contains("owed to") + } + }) { + let bal = b.balance_on(a.id, today); + if bal > Decimal::ZERO { + out.push(Attention { + severity: Severity::Info, + title: format!("{} owed on {}", fmt::dollars(bal), a.name), + detail: "Expenses someone paid personally, not yet reimbursed.".into(), + target: Some(Route::Account(a.id)), + prompt: format!( + "In wanderbooks, list what makes up the {} balance on {} {} and who is owed what.", + fmt::dollars(bal), + a.code, + a.name + ), + }); + } + } + + out.sort_by_key(|a| a.severity); + out +} diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs new file mode 100644 index 0000000..2f72823 --- /dev/null +++ b/crates/ledger-gui/src/books.rs @@ -0,0 +1,519 @@ +//! The view model: one immutable picture of the books, rebuilt whenever the +//! journal grows. Built off the UI thread from a [`Snapshot`]; the UI only +//! reads it. Every number here comes from `ledger-core`; this module only +//! indexes and arranges. + +use chrono::{Datelike, NaiveDate}; +use ledger_config::BooksSettings; +use ledger_core::analysis::{balance_history, balance_series}; +use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period}; +use ledger_core::{AccountId, AccountType, EntryId, EntryStatus, Ledger, Side}; +use ledger_store::{Attachment, JournalLine, Snapshot}; +use rust_decimal::Decimal; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +/// One entry, indexed for listing and detail. +#[derive(Clone, Debug)] +pub struct EntryInfo { + pub id: EntryId, + pub date: NaiveDate, + pub description: String, + pub status: EntryStatus, + /// Total debits. + pub amount: Decimal, + /// Accounts touched, debits first: "6300 Software". + pub accounts: Vec, + /// Indexes into [`Books::attachments`] of linked, current receipts. + pub receipts: Vec, + /// How the entry came to be, in words. + pub origin: &'static str, + /// Indexes into [`Books::journal`] of every event that touched it. + pub events: Vec, + pub vendor: Option, + pub reversal_of: Option, + pub reversed_by: Option, + /// Spending over the receipt threshold with no receipt. + pub missing_receipt: bool, + /// Net effect on cash accounts: positive in, negative out, zero if the + /// entry doesn't touch cash. + pub cash_effect: Decimal, + /// The customer or vendor, from invoices, bills, and vendor tags. + pub party: Option, + /// Lower-cased text for search: description, accounts, vendor, amount. + pub haystack: String, +} + +/// Something the books need, with how to fix it. +#[derive(Clone, Debug)] +pub struct Attention { + pub severity: Severity, + pub title: String, + pub detail: String, + pub target: Option, + /// A prompt to paste into the agent session that fixes it. + pub prompt: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + Problem, + Todo, + Info, +} + +/// The whole picture. +pub struct Books { + pub ledger: Ledger, + pub settings: BooksSettings, + pub data_dir: PathBuf, + pub attachments: Vec, + pub journal: Vec, + pub head: String, + pub today: NaiveDate, + pub loaded_at: chrono::DateTime, + /// Newest first (date, then id). + pub entries: Vec, + pub entry_index: HashMap, + pub cash_accounts: Vec, + pub card_accounts: Vec, + /// Entries created or changed by the most recent refresh. + pub fresh: HashSet, + /// Journal seq before the most recent refresh. + pub fresh_from_seq: u64, + pub receipt_threshold: Decimal, + pub attention: Vec, + pub kpis: Kpis, +} + +/// The headline numbers. +#[derive(Clone, Debug, Default)] +pub struct Kpis { + pub cash: Decimal, + pub cash_30d_ago: Decimal, + pub liabilities: Decimal, + pub receivables: Decimal, + pub revenue_mtd: Decimal, + pub expenses_mtd: Decimal, + /// Last month over the same days as this month so far. + pub revenue_prev_month: Decimal, + pub expenses_prev_month: Decimal, + pub net_ytd: Decimal, + /// Average monthly net loss over the last three full months, if losing. + pub monthly_burn: Option, + pub first_entry: Option, +} + +impl Books { + /// Build the picture from a snapshot. `prev` is the previous picture, + /// to mark what's new. + pub fn build( + snap: &Snapshot, + settings: BooksSettings, + today: NaiveDate, + prev_seq: Option, + ) -> Result { + let ledger = snap.ledger().clone(); + let attachments = snap.attachments(None).map_err(|e| e.to_string())?; + let journal = snap.journal().to_vec(); + let receipt_threshold = settings + .receipt_threshold + .as_deref() + .and_then(|t| t.parse().ok()) + .unwrap_or(Decimal::new(25, 0)); + let cash_accounts = cash_accounts(&ledger, &settings); + let card_accounts: Vec = ledger + .chart() + .all() + .filter(|a| { + a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card") + }) + .map(|a| a.id) + .collect(); + + // Journal events per entry. + let mut events: HashMap> = HashMap::new(); + for (i, line) in journal.iter().enumerate() { + for id in entry_refs(line) { + events.entry(id).or_default().push(i); + } + } + let mut receipts: HashMap> = HashMap::new(); + for (i, a) in attachments.iter().enumerate() { + if let (Some(e), None) = (a.entry_id, &a.withdrawn_at) { + receipts.entry(e).or_default().push(i); + } + } + let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64); + let parties = parties(&ledger, &journal); + let mut fresh = HashSet::new(); + let closings = ledger.closing_entry_ids(); + let mut entries = Vec::new(); + let mut reversed_by: HashMap = HashMap::new(); + for id in 1..ledger.next_entry_id() { + if let Some(e) = ledger.get_entry(id) + && let Some(orig) = e.reversal_of() + { + reversed_by.insert(orig, id); + } + } + for id in 1..ledger.next_entry_id() { + let Some(e) = ledger.get_entry(id) else { + continue; + }; + let evs = events.remove(&id).unwrap_or_default(); + if evs.iter().any(|&i| journal[i].seq > fresh_from_seq) { + fresh.insert(id); + } + let origin = origin(&evs, &journal, closings.contains(&id)); + let mut accounts: Vec = Vec::new(); + for side in [Side::Debit, Side::Credit] { + for l in e.lines().iter().filter(|l| l.side == side) { + if !accounts.contains(&l.account) { + accounts.push(l.account); + } + } + } + let amount: Decimal = e + .lines() + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount.amount) + .sum(); + let rec = receipts.remove(&id).unwrap_or_default(); + let vendor = ledger + .entry_vendor(id) + .and_then(|v| ledger.get_vendor(v)) + .map(|v| v.name.clone()); + // Receipts prove spending: hand-made entries (and recurring + // drafts) that debit an expense or buy an asset from outside. + // Invoices, payments, transfers, draws, closes, and schedules + // carry their own evidence. + let spends = e.lines().iter().any(|l| { + l.side == Side::Debit + && ledger.chart().get(l.account).is_some_and(|a| { + a.account_type == AccountType::Expense + || (a.account_type == AccountType::Asset + && !cash_accounts.contains(&a.id) + && !a.name.to_lowercase().contains("receivable")) + }) + }); + let missing_receipt = matches!(origin, "Journal entry" | "Recurring") + && spends + && rec.is_empty() + && amount > receipt_threshold; + let cash_effect: Decimal = e + .lines() + .iter() + .filter(|l| cash_accounts.contains(&l.account)) + .map(|l| match l.side { + Side::Debit => l.amount.amount, + Side::Credit => -l.amount.amount, + }) + .sum(); + let party = vendor.clone().or_else(|| parties.get(&id).cloned()); + let mut haystack = format!( + "{} {} #{} {}", + e.description(), + crate::fmt::money(amount), + id, + amount + ); + for a in &accounts { + if let Some(acct) = ledger.chart().get(*a) { + haystack.push_str(&format!(" {} {}", acct.code, acct.name)); + } + } + if let Some(v) = &party { + haystack.push(' '); + haystack.push_str(v); + } + entries.push(EntryInfo { + id, + date: e.date(), + description: e.description().to_string(), + status: e.status(), + amount, + accounts, + receipts: rec, + origin, + events: evs, + vendor, + reversal_of: e.reversal_of(), + reversed_by: reversed_by.get(&id).copied(), + missing_receipt, + cash_effect, + party, + haystack: haystack.to_lowercase(), + }); + } + entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.id.cmp(&a.id))); + let entry_index = entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect(); + let mut books = Books { + ledger, + settings, + data_dir: snap.data_dir().to_path_buf(), + attachments, + journal, + head: snap.head_hash().to_string(), + today, + loaded_at: chrono::Utc::now(), + entries, + entry_index, + cash_accounts, + card_accounts, + fresh, + fresh_from_seq, + receipt_threshold, + attention: Vec::new(), + kpis: Kpis::default(), + }; + books.kpis = books.compute_kpis(); + books.attention = crate::attention::find(&books); + Ok(books) + } + + pub fn seq(&self) -> u64 { + self.journal.len() as u64 + } + + pub fn entry(&self, id: EntryId) -> Option<&EntryInfo> { + self.entry_index.get(&id).map(|&i| &self.entries[i]) + } + + pub fn account_label(&self, id: AccountId) -> String { + self.ledger + .chart() + .get(id) + .map(|a| format!("{} {}", a.code, a.name)) + .unwrap_or_else(|| format!("account {id}")) + } + + pub fn account_name(&self, id: AccountId) -> String { + self.ledger + .chart() + .get(id) + .map(|a| a.name.clone()) + .unwrap_or_else(|| format!("account {id}")) + } + + pub fn company_name(&self) -> &str { + &self.settings.company.name + } + + /// Cash balance at the end of each day it changed. + pub fn cash_history(&self) -> Vec<(NaiveDate, Decimal)> { + balance_history(&self.ledger, &self.cash_accounts) + } + + /// Normal-side balance of one account at the end of `date`. + pub fn balance_on(&self, account: AccountId, date: NaiveDate) -> Decimal { + balance_series(&self.ledger, &[account], &[date])[0] + } + + /// First day of the current month. + pub fn month_start(&self) -> NaiveDate { + NaiveDate::from_ymd_opt(self.today.year(), self.today.month(), 1).expect("valid") + } + + /// Range of the last `n` months, ending with the current one. + pub fn trailing_months(&self, n: u32) -> (NaiveDate, NaiveDate) { + let start = self.month_start() - chrono::Months::new(n.saturating_sub(1)); + (start, self.today) + } + + /// The fiscal year to date. + pub fn fiscal_ytd(&self) -> (NaiveDate, NaiveDate) { + let (start, _) = self.settings.company.fiscal_year_containing(self.today); + (start, self.today) + } + + fn compute_kpis(&self) -> Kpis { + let l = &self.ledger; + let today = self.today; + let cash = balance_series( + l, + &self.cash_accounts, + &[today - chrono::Days::new(30), today], + ); + let bs = balance_sheet_as_of(l, today).ok(); + let receivables: Decimal = l + .chart() + .all() + .filter(|a| { + a.account_type == AccountType::Asset && a.name.to_lowercase().contains("receivable") + }) + .map(|a| self.balance_on(a.id, today)) + .sum(); + let ms = self.month_start(); + let prev_start = ms - chrono::Months::new(1); + let prev_end = ms - chrono::Days::new(1); + let pl = |s, e| profit_and_loss_for_period(l, s, e, None).ok(); + let mtd = pl(ms, today); + // Same days of last month: a fair comparison mid-month. + let same_day = (prev_start + chrono::Days::new(u64::from(today.day() - 1))).min(prev_end); + let prev = pl(prev_start, same_day); + let (fy_start, _) = self.fiscal_ytd(); + let ytd = pl(fy_start, today); + let three = pl(ms - chrono::Months::new(3), prev_end); + let first_entry = self + .entries + .iter() + .filter(|e| e.status == EntryStatus::Posted) + .map(|e| e.date) + .min(); + let monthly_burn = three.and_then(|p| { + let loss = p.total_expenses - p.total_revenue; + // Only months the books cover count toward the average. + let months = match first_entry { + Some(f) if f > ms - chrono::Months::new(3) => { + let m = (prev_end.year() * 12 + prev_end.month() as i32) + - (f.year() * 12 + f.month() as i32) + + 1; + m.max(0) + } + Some(_) => 3, + None => 0, + }; + (loss > Decimal::ZERO && months > 0).then(|| (loss / Decimal::from(months)).round_dp(2)) + }); + Kpis { + cash: cash[1], + cash_30d_ago: cash[0], + liabilities: bs.as_ref().map(|b| b.total_liabilities).unwrap_or_default(), + receivables, + revenue_mtd: mtd.as_ref().map(|p| p.total_revenue).unwrap_or_default(), + expenses_mtd: mtd.as_ref().map(|p| p.total_expenses).unwrap_or_default(), + revenue_prev_month: prev.as_ref().map(|p| p.total_revenue).unwrap_or_default(), + expenses_prev_month: prev.as_ref().map(|p| p.total_expenses).unwrap_or_default(), + net_ytd: ytd + .map(|p| p.total_revenue - p.total_expenses) + .unwrap_or_default(), + monthly_burn, + first_entry, + } + } + + /// Blob store path for an attachment. + pub fn blob_root(&self) -> PathBuf { + self.data_dir.join("blobs") + } +} + +/// Customer or vendor names for entries created by invoices and bills and +/// their payments, from the journal. +fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap { + let mut out = HashMap::new(); + let customer = |id: Option| { + id.and_then(|c| ledger.get_customer(c)) + .map(|c| c.name.clone()) + }; + let vendor = |id: Option| { + id.and_then(|v| ledger.get_vendor(v)) + .map(|v| v.name.clone()) + }; + let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64()); + for line in journal { + let p = &line.payload; + let Some(entry) = u(p, "entry_id") else { + continue; + }; + let name = match line.line_type.as_str() { + "invoice.created" => customer(u(p, "customer_id")), + "invoice.payment_received" => customer( + u(p, "invoice_id") + .and_then(|i| ledger.get_invoice(i)) + .map(|i| i.customer_id), + ), + "bill.created" => vendor(u(p, "vendor_id")), + "bill.paid" => vendor( + u(p, "bill_id") + .and_then(|b| ledger.get_bill(b)) + .map(|b| b.vendor_id), + ), + _ => None, + }; + if let Some(n) = name { + out.insert(entry, n); + } + } + out +} + +/// Entry ids a journal line refers to. +pub fn entry_refs(line: &JournalLine) -> Vec { + let p = &line.payload; + let mut out = Vec::new(); + let mut push = |v: Option<&Value>| { + if let Some(id) = v.and_then(|v| v.as_u64()) + && !out.contains(&id) + { + out.push(id); + } + }; + push(p.get("entry_id")); + push(p.get("closing_entry")); + push(p.get("entry").and_then(|e| e.get("id"))); + if line.line_type.starts_with("entry.") { + push(p.get("id")); + } + out +} + +fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static str { + if closing { + return "Period close"; + } + let has = |t: &str| events.iter().any(|&i| journal[i].line_type == t); + if has("entry.reversed") { + "Reversal" + } else if has("schedule.posted") { + "Amortization" + } else if has("asset.depreciation_posted") { + "Depreciation" + } else if has("asset.disposed") { + "Asset disposal" + } else if has("bill.created") { + "Bill" + } else if has("bill.paid") { + "Bill payment" + } else if has("invoice.created") { + "Invoice" + } else if has("invoice.payment_received") { + "Customer payment" + } else if has("payroll.run") { + "Payroll" + } else if has("payroll.remitted") || has("payroll.taxes_remitted") { + "Payroll taxes" + } else if has("recurring.generated") { + "Recurring" + } else { + "Journal entry" + } +} + +/// Cash accounts: from `[books] cash_accounts`, else asset accounts whose +/// names say they hold cash. +fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec { + if !settings.cash_accounts.is_empty() { + return settings + .cash_accounts + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect(); + } + ledger + .chart() + .all() + .filter(|a| { + let n = a.name.to_lowercase(); + a.account_type == AccountType::Asset + && !a.placeholder + && ["cash", "bank", "checking", "savings", "money market"] + .iter() + .any(|w| n.contains(w)) + }) + .map(|a| a.id) + .collect() +} diff --git a/crates/ledger-gui/src/charts.rs b/crates/ledger-gui/src/charts.rs new file mode 100644 index 0000000..a81c92a --- /dev/null +++ b/crates/ledger-gui/src/charts.rs @@ -0,0 +1,387 @@ +//! Charts, painted directly: a balance line (step-shaped, because a +//! balance holds until the next transaction), grouped bars, ranked +//! horizontal bars, and sparklines. Every point answers hover with its +//! exact value; bars report clicks so they can drill down. + +use crate::fmt; +use crate::theme::{self, pal}; +use chrono::NaiveDate; +use egui::{ + Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2, +}; +use rust_decimal::Decimal; + +fn f(d: Decimal) -> f64 { + d.try_into().unwrap_or(0.0) +} + +/// "Nice" axis ticks covering [lo, hi]. +fn ticks(lo: f64, hi: f64, n: usize) -> Vec { + let span = (hi - lo).max(1e-9); + let raw = span / n as f64; + let mag = 10f64.powf(raw.log10().floor()); + let step = [1.0, 2.0, 2.5, 5.0, 10.0] + .iter() + .map(|m| m * mag) + .find(|s| span / s <= n as f64) + .unwrap_or(10.0 * mag); + let start = (lo / step).floor() * step; + let mut v = vec![start]; + // Up to the first tick at or above the top, so the data never pokes + // past the last gridline. + while *v.last().expect("non-empty") < hi - step * 1e-9 { + let next = v.last().expect("non-empty") + step; + v.push(next); + } + v +} + +fn money_label(v: f64) -> String { + fmt::compact(Decimal::from_f64_retain(v).unwrap_or_default()) +} + +/// A balance over time as a step line with a soft fill. `points` are +/// (date, balance at end of day), ascending; the line runs to `until`. +pub fn balance_line(ui: &mut Ui, points: &[(NaiveDate, Decimal)], until: NaiveDate, height: f32) { + let p = pal(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover()); + let painter = ui.painter_at(rect); + if points.is_empty() { + painter.text( + rect.center(), + Align2::CENTER_CENTER, + "No cash activity yet", + theme::regular(12.5), + p.faint, + ); + return; + } + let first = points[0].0; + let last = until.max(points[points.len() - 1].0); + let days = ((last - first).num_days().max(1)) as f64; + let vals: Vec = points.iter().map(|(_, v)| f(*v)).collect(); + let lo = vals.iter().cloned().fold(0.0f64, f64::min); + let hi = vals.iter().cloned().fold(f64::MIN, f64::max).max(lo + 1.0); + let ts = ticks(lo, hi, 4); + let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi)); + let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(8.0, 22.0)); + let x = + |d: NaiveDate| plot.left() + ((d - first).num_days() as f64 / days) as f32 * plot.width(); + let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height(); + for t in &ts { + let yy = y(*t); + painter.line_segment( + [pos2(plot.left(), yy), pos2(plot.right(), yy)], + Stroke::new(1.0, p.border), + ); + painter.text( + pos2(plot.left() - 8.0, yy), + Align2::RIGHT_CENTER, + money_label(*t), + theme::regular(11.0), + p.faint, + ); + } + // X labels: first, last, and a few between. + let n_labels = 4; + for i in 0..=n_labels { + let d = first + chrono::Days::new(((days * i as f64) / n_labels as f64).round() as u64); + let align = match i { + 0 => Align2::LEFT_TOP, + i if i == n_labels => Align2::RIGHT_TOP, + _ => Align2::CENTER_TOP, + }; + painter.text( + pos2(x(d), plot.bottom() + 6.0), + align, + fmt::date_short(d), + theme::regular(11.0), + p.faint, + ); + } + // Step path. + let mut line: Vec = Vec::new(); + for (i, (d, v)) in points.iter().enumerate() { + let px = x(*d); + if i > 0 { + line.push(pos2(px, line.last().unwrap().y)); + } + line.push(pos2(px, y(f(*v)))); + } + line.push(pos2(x(last), line.last().unwrap().y)); + let base = y(0.0f64.clamp(lo, hi)); + let mut fill = line.clone(); + fill.push(pos2(x(last), base)); + fill.push(pos2(x(first), base)); + let soft = Color32::from_rgba_unmultiplied(p.accent.r(), p.accent.g(), p.accent.b(), 28); + // Fill: one mesh of strips under each flat step (the outline may be + // non-convex). A mesh has no anti-aliased edges, so strips meet without + // seams. + let mut mesh = egui::Mesh::default(); + for w in line.windows(2) { + if (w[0].y - w[1].y).abs() < 0.01 && w[1].x > w[0].x { + mesh.add_colored_rect( + Rect::from_min_max( + pos2(w[0].x, w[0].y.min(base)), + pos2(w[1].x, w[0].y.max(base)), + ), + soft, + ); + } + } + painter.add(Shape::mesh(mesh)); + let _ = fill; + painter.add(Shape::line(line.clone(), Stroke::new(2.0, p.accent))); + // Hover: the balance on the hovered day. + if let Some(hp) = resp.hover_pos() + && plot.x_range().contains(hp.x) + { + let frac = ((hp.x - plot.left()) / plot.width()) as f64; + let day = first + chrono::Days::new((frac * days).round() as u64); + let bal = points + .iter() + .take_while(|(d, _)| *d <= day) + .last() + .map(|(_, v)| *v); + if let Some(b) = bal { + let hx = x(day); + painter.line_segment( + [pos2(hx, plot.top()), pos2(hx, plot.bottom())], + Stroke::new(1.0, p.faint), + ); + painter.circle_filled(pos2(hx, y(f(b))), 4.0, p.accent); + tooltip( + &painter, + pos2(hx, plot.top()), + &fmt::date(day), + &fmt::dollars(b), + p.surface, + p.border, + p.text, + p.muted, + rect, + ); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn tooltip( + painter: &Painter, + at: Pos2, + title: &str, + value: &str, + bg: Color32, + border: Color32, + fg: Color32, + muted: Color32, + bounds: Rect, +) { + let t1 = painter.layout_no_wrap(title.to_string(), theme::regular(11.5), muted); + let t2 = painter.layout_no_wrap(value.to_string(), theme::semibold(13.0), fg); + let w = t1.size().x.max(t2.size().x) + 16.0; + let h = t1.size().y + t2.size().y + 12.0; + let mut r = Rect::from_min_size(pos2(at.x + 8.0, at.y), vec2(w, h)); + if r.right() > bounds.right() { + r = r.translate(vec2(-w - 16.0, 0.0)); + } + painter.rect( + r, + CornerRadius::same(6), + bg, + Stroke::new(1.0, border), + egui::StrokeKind::Inside, + ); + painter.galley(r.min + vec2(8.0, 6.0), t1.clone(), muted); + painter.galley(r.min + vec2(8.0, 6.0 + t1.size().y), t2, fg); +} + +/// One group of bars on a [`bar_chart`]. +pub struct BarGroup { + pub label: String, + pub values: Vec, +} + +/// Grouped bars (e.g. income and spending per month), with a legend. +/// Returns the clicked group, if any. +pub fn bar_chart( + ui: &mut Ui, + groups: &[BarGroup], + series: &[(&str, Color32)], + height: f32, +) -> Option { + let p = pal(ui.ctx()); + ui.horizontal(|ui| { + for (name, color) in series { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(3), *color); + ui.label( + egui::RichText::new(*name) + .font(theme::regular(12.0)) + .color(p.muted), + ); + ui.add_space(8.0); + } + }); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click()); + let painter = ui.painter_at(rect); + if groups.is_empty() { + return None; + } + let all: Vec = groups + .iter() + .flat_map(|g| g.values.iter().map(|v| f(*v))) + .collect(); + let lo = all.iter().cloned().fold(0.0f64, f64::min); + let hi = all.iter().cloned().fold(0.0f64, f64::max).max(lo + 1.0); + let ts = ticks(lo, hi, 4); + let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi)); + let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(4.0, 22.0)); + let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height(); + for t in &ts { + let yy = y(*t); + painter.line_segment( + [pos2(plot.left(), yy), pos2(plot.right(), yy)], + Stroke::new(1.0, p.border), + ); + painter.text( + pos2(plot.left() - 8.0, yy), + Align2::RIGHT_CENTER, + money_label(*t), + theme::regular(11.0), + p.faint, + ); + } + let gw = plot.width() / groups.len() as f32; + let n = series.len().max(1) as f32; + let bw = ((gw * 0.7) / n).min(28.0); + let mut clicked = None; + let hover = resp.hover_pos(); + for (gi, g) in groups.iter().enumerate() { + let gx = plot.left() + gw * gi as f32; + let grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, plot.bottom())); + let hovered = hover.is_some_and(|h| grect.contains(h)); + if hovered { + painter.rect_filled(grect, CornerRadius::same(6), p.hover); + } + let total_w = bw * n + 3.0 * (n - 1.0); + let start = gx + (gw - total_w) / 2.0; + for (si, v) in g.values.iter().enumerate() { + let color = series.get(si).map(|s| s.1).unwrap_or(p.accent); + let x0 = start + si as f32 * (bw + 3.0); + let (y0, y1) = (y(0.0), y(f(*v))); + let r = Rect::from_min_max( + pos2(x0, y0.min(y1)), + pos2(x0 + bw, y0.max(y1).max(y0.min(y1) + 1.0)), + ); + painter.rect_filled( + r, + CornerRadius { + nw: 3, + ne: 3, + sw: 0, + se: 0, + }, + color, + ); + } + painter.text( + pos2(gx + gw / 2.0, plot.bottom() + 6.0), + Align2::CENTER_TOP, + &g.label, + theme::regular(11.0), + p.faint, + ); + if hovered { + let lines: Vec = series + .iter() + .zip(&g.values) + .map(|((name, _), v)| format!("{name}: {}", fmt::dollars(*v))) + .collect(); + tooltip( + &painter, + pos2(gx + gw / 2.0, plot.top()), + &g.label, + &lines.join("\n"), + p.surface, + p.border, + p.text, + p.muted, + rect, + ); + if resp.clicked() { + clicked = Some(gi); + } + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + clicked +} + +/// Ranked horizontal bars with labels and exact amounts. Returns the +/// clicked row. +pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option { + let p = pal(ui.ctx()); + let max = rows + .iter() + .map(|(_, v)| f(*v).abs()) + .fold(0.0, f64::max) + .max(1e-9); + let mut clicked = None; + for (i, (label, v)) in rows.iter().enumerate() { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + let painter = ui.painter_at(rect); + if resp.hovered() { + painter.rect_filled(rect, CornerRadius::same(6), p.hover); + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + let amount = fmt::dollars(*v); + let ag = painter.layout_no_wrap(amount, theme::regular(13.0), p.text); + let label_w = (rect.width() * 0.38).min(220.0); + let lg = painter.layout(label.clone(), theme::regular(13.0), p.text, label_w); + painter.galley( + pos2(rect.left() + 6.0, rect.center().y - lg.size().y / 2.0), + lg, + p.text, + ); + let bar_left = rect.left() + label_w + 14.0; + let bar_max = rect.right() - ag.size().x - 18.0 - bar_left; + let w = (f(*v).abs() / max) as f32 * bar_max.max(0.0); + let bar = Rect::from_min_size( + pos2(bar_left, rect.center().y - 6.0), + vec2(w.max(2.0), 12.0), + ); + painter.rect_filled(bar, CornerRadius::same(3), color); + painter.galley( + pos2( + rect.right() - ag.size().x - 6.0, + rect.center().y - ag.size().y / 2.0, + ), + ag, + p.text, + ); + if resp.clicked() { + clicked = Some(i); + } + } + clicked +} + +/// A tiny trend line. +pub fn sparkline(painter: &Painter, rect: Rect, values: &[f64], color: Color32) { + let lo = values.iter().cloned().fold(f64::MAX, f64::min); + let hi = values.iter().cloned().fold(f64::MIN, f64::max); + let span = (hi - lo).max(1e-9); + let n = values.len().max(2) - 1; + let pts: Vec = values + .iter() + .enumerate() + .map(|(i, v)| { + pos2( + rect.left() + rect.width() * i as f32 / n as f32, + rect.bottom() - ((v - lo) / span) as f32 * rect.height(), + ) + }) + .collect(); + painter.add(Shape::line(pts, Stroke::new(1.5, color))); +} diff --git a/crates/ledger-gui/src/data.rs b/crates/ledger-gui/src/data.rs new file mode 100644 index 0000000..490b437 --- /dev/null +++ b/crates/ledger-gui/src/data.rs @@ -0,0 +1,158 @@ +//! The live loader. A background thread owns the read-only [`Snapshot`], +//! watches the data directory, and on every change applies the new journal +//! lines and sends a fresh [`Books`] to the UI, then asks for a repaint. +//! A change made by the agent in another window shows up within a frame or +//! two. Filesystem events are the trigger; a slow poll backs them up in case +//! an event is missed (network drives, odd editors). + +use crate::books::Books; +use chrono::NaiveDate; +use ledger_config::BooksSettings; +use ledger_store::Snapshot; +use notify::{RecursiveMode, Watcher}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::time::{Duration, SystemTime}; + +/// Where the books come from. +#[derive(Clone, Debug)] +pub struct Source { + /// The `books-data` directory (journal and blobs). + pub data_dir: PathBuf, + /// The company's `wanderbooks.toml`, if any. + pub books_toml: Option, + /// Fix "today" (tests, screenshots); otherwise the local date. + pub today: Option, +} + +pub enum Update { + Books(Arc), + Error(String), +} + +pub struct Loader { + rx: Receiver, +} + +impl Loader { + pub fn spawn(source: Source, ctx: egui::Context) -> Loader { + let (tx, rx) = channel(); + std::thread::Builder::new() + .name("books-loader".into()) + .spawn(move || run(source, ctx, tx)) + .expect("spawn loader"); + Loader { rx } + } + + /// The newest update, if any arrived since the last call. + pub fn poll(&self) -> Option { + let mut last = None; + while let Ok(u) = self.rx.try_recv() { + last = Some(u); + } + last + } + + /// Block until the first update (tests, headless). + pub fn wait(&self, timeout: Duration) -> Option { + self.rx.recv_timeout(timeout).ok() + } +} + +/// Load once, synchronously (tests and one-shot tools). +pub fn load_once(source: &Source) -> Result { + let snap = Snapshot::load(&source.data_dir).map_err(|e| e.to_string())?; + Books::build(&snap, settings(source)?, today(source), None) +} + +fn today(source: &Source) -> NaiveDate { + source + .today + .unwrap_or_else(|| chrono::Local::now().date_naive()) +} + +fn settings(source: &Source) -> Result { + match &source.books_toml { + Some(p) if p.exists() => ledger_config::load(p), + _ => Ok(BooksSettings::default()), + } +} + +fn mtime(p: &Option) -> Option { + p.as_ref() + .and_then(|p| std::fs::metadata(p).ok()) + .and_then(|m| m.modified().ok()) +} + +fn run(source: Source, ctx: egui::Context, tx: Sender) { + let send = |u: Update| { + let _ = tx.send(u); + ctx.request_repaint(); + }; + let mut snap = loop { + match Snapshot::load(&source.data_dir) { + Ok(s) => break s, + Err(e) => { + send(Update::Error(format!( + "Can't read the books in {}: {e}", + source.data_dir.display() + ))); + std::thread::sleep(Duration::from_secs(2)); + } + } + }; + let build = |snap: &Snapshot, prev: Option| -> Update { + match settings(&source).and_then(|s| Books::build(snap, s, today(&source), prev)) { + Ok(b) => Update::Books(Arc::new(b)), + Err(e) => Update::Error(e), + } + }; + send(build(&snap, None)); + + let (ev_tx, ev_rx) = channel(); + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + if res.is_ok() { + let _ = ev_tx.send(()); + } + }) + .ok(); + if let Some(w) = watcher.as_mut() { + let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive); + if let Some(t) = &source.books_toml + && let Some(dir) = t.parent() + { + let _ = w.watch(dir, RecursiveMode::NonRecursive); + } + } + let mut toml_mtime = mtime(&source.books_toml); + let mut day = today(&source); + loop { + match ev_rx.recv_timeout(Duration::from_secs(3)) { + Ok(()) => { + // Let a burst of writes settle, then take them all at once. + std::thread::sleep(Duration::from_millis(30)); + while ev_rx.try_recv().is_ok() {} + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + std::thread::sleep(Duration::from_secs(3)); + } + } + let before = snap.journal_seq(); + let grew = match snap.refresh() { + Ok(n) => n > 0, + Err(e) => { + send(Update::Error(format!("Reading the journal failed: {e}"))); + continue; + } + }; + let toml_now = mtime(&source.books_toml); + let new_day = today(&source); + if grew || toml_now != toml_mtime || new_day != day { + toml_mtime = toml_now; + day = new_day; + send(build(&snap, grew.then_some(before))); + } + } +} diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs new file mode 100644 index 0000000..c5a1f4b --- /dev/null +++ b/crates/ledger-gui/src/demo.rs @@ -0,0 +1,796 @@ +//! A demo company, built through the real engine: fifteen months of a +//! small design studio (single-member LLC) with client invoices, template +//! sales paid out by a platform, contractors (one without a W-9), rent on a +//! recurring schedule, a credit card, a laptop on depreciation, prepaid +//! insurance, owner draws, a closed year, PDF receipts, and a few loose +//! ends for the "needs attention" list. Deterministic for a given `today`. + +use chrono::{Datelike, Days, Months, NaiveDate}; +use ledger_core::{ + AccountType, CloseTo, Currency, Form1099, Frequency, Money, NewAsset, NewRecurring, + NewSchedule, ScheduleKind, Side, arap::DocumentLine, +}; +use ledger_store::{Store, StoreError}; +use rust_decimal::Decimal; +use std::path::Path; + +/// The demo's `wanderbooks.toml`. +pub const BOOKS_TOML: &str = r#"[company] +name = "Juniper Studio LLC" +entity_type = "LLC, single-member" +tax_classification = "disregarded entity" # Schedule C +fiscal_year_start = "01-01" +formation_state = "OR" +formed = "2025-06-02" +ein = "00-0000000" +address = "1200 NW Everett St, Portland, OR 97209" +owner = "Alex Rivera" + +[books] +accounting_basis = "accrual" +receipt_threshold = "25.00" +close_into = "3000" +close_sweep = ["3100"] +cash_accounts = ["1000", "1010"] +"#; + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 33 + } + /// Uniform in [lo, hi] dollars, to the cent. + fn amount(&mut self, lo: u64, hi: u64) -> Decimal { + let cents = lo * 100 + self.next() % ((hi - lo) * 100 + 1); + Decimal::new(cents as i64, 2) + } + fn pick(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +fn usd(d: Decimal) -> Money { + Money::new(d, Currency::USD) +} + +fn dec(n: i64) -> Decimal { + Decimal::from(n) +} + +fn day(month_start: NaiveDate, d: u32) -> NaiveDate { + let last = (month_start + Months::new(1) - Days::new(1)).day(); + NaiveDate::from_ymd_opt(month_start.year(), month_start.month(), d.min(last)).expect("valid") +} + +/// A one-page PDF receipt with the given lines (Helvetica). +pub fn receipt_pdf(title: &str, lines: &[String]) -> Vec { + let esc = |s: &str| { + s.replace('\\', "\\\\") + .replace('(', "\\(") + .replace(')', "\\)") + }; + let mut content = String::new(); + content.push_str("BT /F2 22 Tf 56 760 Td ("); + content.push_str(&esc(title)); + content.push_str(") Tj ET\n"); + content.push_str("0.85 0.85 0.85 RG 56 742 m 556 742 l S\n"); + let mut y = 712; + for l in lines { + let (font, size) = if l.starts_with("TOTAL") { + ("F2", 13) + } else { + ("F1", 11) + }; + content.push_str(&format!( + "BT /{font} {size} Tf 56 {y} Td ({}) Tj ET\n", + esc(l) + )); + y -= 20; + } + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + ]; + let mut pdf = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice( + format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(), + ); + for off in offsets { + pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", + objects.len() + 1 + ) + .as_bytes(), + ); + pdf +} + +fn receipt(vendor: &str, date: NaiveDate, item: &str, amount: Decimal, paid: &str) -> Vec { + receipt_pdf( + vendor, + &[ + format!("Receipt {}", date.format("%B %-d, %Y")), + format!( + "Order {:08}", + (date.num_days_from_ce() as u64 * 7919 + + (amount * dec(100)).try_into().unwrap_or(0u64)) + % 100_000_000 + ), + String::new(), + item.to_string(), + format!("Amount: ${amount}"), + String::new(), + format!("TOTAL ${amount}"), + format!("Paid with {paid}"), + String::new(), + "Thank you for your business.".into(), + ], + ) +} + +/// Build the demo books in `dir` (which must not already hold books): the +/// journal and blobs under `dir/books-data`, the settings in +/// `dir/wanderbooks.toml`. +pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { + std::fs::create_dir_all(dir)?; + std::fs::write(dir.join("wanderbooks.toml"), BOOKS_TOML)?; + let mut s = Store::create(&dir.join("books-data"), Currency::USD)?; + let mut rng = Rng(0x5eed_1234); + let start = + NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid") - Months::new(15); + let this_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid"); + + let acct = + |s: &mut Store, code: &str, name: &str, t, tax: Option<&str>| -> Result { + let id = s.add_account(code, name, t, None, false)?; + if let Some(c) = tax { + s.set_account_tax(id, Some(c), None)?; + } + Ok(id) + }; + use AccountType::*; + let checking = acct(&mut s, "1000", "Operating Checking", Asset, None)?; + let savings = acct(&mut s, "1010", "Savings", Asset, None)?; + let prepaid = acct(&mut s, "1300", "Prepaid Insurance", Asset, None)?; + let equipment = acct(&mut s, "1500", "Computer Equipment", Asset, None)?; + let accum = acct(&mut s, "1510", "Accumulated Depreciation", Asset, None)?; + let card = acct(&mut s, "2100", "Business Credit Card", Liability, None)?; + let capital = acct(&mut s, "3000", "Owner's Capital", Equity, None)?; + let draws = acct(&mut s, "3100", "Owner's Draws", Equity, None)?; + let _services = acct( + &mut s, + "4000", + "Design Services", + Revenue, + Some("gross_receipts"), + )?; + let templates = acct( + &mut s, + "4100", + "Template Sales", + Revenue, + Some("gross_receipts"), + )?; + let fees = acct( + &mut s, + "6000", + "Platform Fees", + Expense, + Some("commissions_fees"), + )?; + let contract = acct( + &mut s, + "6050", + "Contract Labor", + Expense, + Some("contract_labor"), + )?; + let software = acct( + &mut s, + "6100", + "Software & Subscriptions", + Expense, + Some("office_expense"), + )?; + let rent = acct( + &mut s, + "6200", + "Studio Rent", + Expense, + Some("rent_property"), + )?; + let insurance = acct(&mut s, "6300", "Insurance", Expense, Some("insurance"))?; + let advertising = acct(&mut s, "6400", "Advertising", Expense, Some("advertising"))?; + let meals = acct(&mut s, "6500", "Meals", Expense, Some("meals"))?; + let travel = acct(&mut s, "6600", "Travel", Expense, Some("travel"))?; + let supplies = acct(&mut s, "6700", "Office Supplies", Expense, Some("supplies"))?; + let depreciation = acct( + &mut s, + "6800", + "Depreciation", + Expense, + Some("depreciation"), + )?; + let bank_fees = acct(&mut s, "6900", "Bank Fees", Expense, None)?; + let client_class = s.add_class("Client work")?; + let template_class = s.add_class("Templates")?; + + let sam = s.add_vendor("Sam Okafor")?; + s.set_vendor_1099(sam, Some(Form1099::Nec), true)?; + let riley = s.add_vendor("Riley Chen")?; + s.set_vendor_1099(riley, Some(Form1099::Nec), false)?; + let landlord = s.add_vendor("Everett Lofts LLC")?; + s.set_vendor_1099(landlord, Some(Form1099::Misc), true)?; + let customers: Vec = [ + "Fern & Field Coffee", + "Bramble Outdoor Co.", + "Northwind Clinics", + "Tidepool Records", + "Larkspur Bakery", + ] + .iter() + .map(|n| s.add_customer(n)) + .collect::>()?; + + // Formation documents. + s.file_document( + "formation", + "Articles of Organization.pdf", + "application/pdf", + &receipt_pdf( + "Articles of Organization", + &[ + "Juniper Studio LLC".into(), + "Oregon Secretary of State".into(), + format!("Filed {}", start.format("%B %-d, %Y")), + ], + ), + Some("Filed with the state"), + )?; + s.file_document( + "tax", + "IRS EIN confirmation.pdf", + "application/pdf", + &receipt_pdf( + "EIN Assignment", + &[ + "Juniper Studio LLC".into(), + "Employer Identification Number assigned".into(), + ], + ), + Some("CP 575"), + )?; + + // Helper: post a two-line entry with an optional receipt and vendor. + #[allow(clippy::too_many_arguments)] + fn post( + s: &mut Store, + date: NaiveDate, + desc: &str, + dr: u64, + cr: u64, + amount: Decimal, + receipt: Option<(&str, Vec)>, + vendor: Option, + class: Option, + ) -> Result { + let e = s.create_entry(date, desc)?; + s.add_line(e, dr, Side::Debit, usd(amount))?; + s.add_line(e, cr, Side::Credit, usd(amount))?; + if let Some(c) = class { + s.set_line_class(e, 0, Some(c))?; + s.set_line_class(e, 1, Some(c))?; + } + if let Some((name, bytes)) = receipt { + s.attach_receipt(Some(e), name, "application/pdf", &bytes, None)?; + } + s.post_entry(e)?; + if vendor.is_some() { + s.set_entry_vendor(e, vendor)?; + } + Ok(e) + } + + post( + &mut s, + day(start, 2), + "Owner contribution — startup capital", + checking, + capital, + dec(15000), + Some(( + "bank-transfer.pdf", + receipt( + "Transfer confirmation", + day(start, 2), + "Transfer from Alex Rivera personal checking", + dec(15000), + "ACH", + ), + )), + None, + None, + )?; + + // Rent: a recurring entry, posted every month with its invoice; this + // month's is left as a draft. + s.register_recurring(NewRecurring { + description: "Studio rent — Everett Lofts".into(), + lines: vec![ + (rent, Side::Debit, usd(dec(1800))), + (checking, Side::Credit, usd(dec(1800))), + ], + frequency: Frequency::Monthly, + start: day(start, 1), + end: None, + })?; + + // Laptop and its depreciation. + let laptop_date = day(start + Months::new(1), 9); + let laptop = dec(3199); + post( + &mut s, + laptop_date, + "MacBook Pro 16\" — studio workstation", + equipment, + card, + laptop, + Some(( + "apple-store.pdf", + receipt( + "Apple Store", + laptop_date, + "MacBook Pro 16-inch, M-series", + laptop, + "Visa ending 4242", + ), + )), + None, + None, + )?; + let asset = s.register_asset(NewAsset { + description: "MacBook Pro 16\"".into(), + purchase_date: laptop_date, + cost: usd(laptop), + salvage_value: usd(dec(400)), + useful_life_months: 36, + cost_account: equipment, + accum_depr_account: accum, + expense_account: depreciation, + })?; + + // Prepaid insurance, spread over a year. + let ins_date = day(start + Months::new(2), 15); + post( + &mut s, + ins_date, + "General liability insurance — annual premium", + prepaid, + checking, + dec(1440), + Some(( + "hiscox-policy.pdf", + receipt( + "Hiscox", + ins_date, + "General liability, 12 months", + dec(1440), + "ACH", + ), + )), + None, + None, + )?; + s.register_schedule(NewSchedule { + description: "General liability insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total: usd(dec(1440)), + start: ins_date, + months: 12, + balance_account: prepaid, + pl_account: insurance, + })?; + + let mut open_invoices: Vec<(u64, NaiveDate, Decimal)> = Vec::new(); + let mut card_balance = Decimal::ZERO; + let mut m = start; + let mut month_index = 0; + while m <= this_month { + let current = m == this_month; + let in_range = |d: NaiveDate| d <= today; + // Software on the card. + for (vendor, item, lo, hi, d) in [ + ("Figma", "Figma Professional — monthly", 45, 45, 3), + ("Adobe", "Creative Cloud All Apps", 59, 60, 7), + ("Google Workspace", "Business Starter — 2 users", 28, 28, 11), + ] { + let date = day(m, d); + if !in_range(date) { + continue; + } + let amt = rng.amount(lo, hi); + post( + &mut s, + date, + &format!("{vendor} — {item}"), + software, + card, + amt, + Some(( + &format!("{}-{}.pdf", vendor.to_lowercase().replace(' ', "-"), date), + receipt(vendor, date, item, amt, "Visa ending 4242"), + )), + None, + None, + )?; + card_balance += amt; + } + // Advertising some months. + if month_index % 3 == 1 { + let date = day(m, 14); + if in_range(date) { + let amt = rng.amount(150, 420); + post( + &mut s, + date, + "Instagram ads — portfolio campaign", + advertising, + card, + amt, + Some(( + "meta-ads.pdf", + receipt("Meta Ads", date, "Sponsored posts", amt, "Visa ending 4242"), + )), + None, + None, + )?; + card_balance += amt; + } + } + // Meals and supplies. + let date = day(m, 17); + if in_range(date) { + let amt = rng.amount(28, 96); + let missing = current || month_index == 13; + post( + &mut s, + date, + "Client lunch — project kickoff", + meals, + card, + amt, + (!missing).then(|| { + ( + "lunch.pdf", + receipt( + "Tasty n Alder", + date, + "Lunch for 2", + amt, + "Visa ending 4242", + ), + ) + }), + None, + None, + )?; + card_balance += amt; + } + if month_index % 4 == 2 { + let date = day(m, 21); + if in_range(date) { + let amt = rng.amount(40, 180); + post( + &mut s, + date, + "Paper, ink, and sketchbooks", + supplies, + card, + amt, + Some(( + "blick.pdf", + receipt( + "Blick Art Materials", + date, + "Supplies", + amt, + "Visa ending 4242", + ), + )), + None, + None, + )?; + card_balance += amt; + } + } + if month_index == 7 { + let date = day(m, 9); + if in_range(date) { + let amt = dec(1186) + rng.amount(0, 90); + post( + &mut s, + date, + "Flights and hotel — client workshop, Austin", + travel, + card, + amt, + Some(( + "alaska-air.pdf", + receipt( + "Alaska Airlines", + date, + "PDX-AUS round trip + 2 nights", + amt, + "Visa ending 4242", + ), + )), + None, + None, + )?; + card_balance += amt; + } + } + // Pay the card in full on the 25th. + let date = day(m, 25); + if in_range(date) && card_balance > Decimal::ZERO { + post( + &mut s, + date, + "Credit card payment", + card, + checking, + card_balance, + Some(( + "card-statement.pdf", + receipt( + "Chase Ink", + date, + "Statement balance payment", + card_balance, + "ACH from Operating Checking", + ), + )), + None, + None, + )?; + card_balance = Decimal::ZERO; + } + // Client invoices: two or three a month, net 30. + for k in 0..(2 + rng.pick(2)) { + let date = day(m, 5 + 9 * k as u32); + if !in_range(date) { + continue; + } + let cust = customers[rng.pick(customers.len() as u64) as usize]; + let amt = (rng.amount(1800, 7800) / dec(50)).round() * dec(50); + let inv = s.create_invoice( + cust, + date, + date + Days::new(30), + vec![DocumentLine { + description: [ + "Brand identity", + "Website design", + "Packaging", + "Illustration set", + "Design retainer", + ][rng.pick(5) as usize] + .into(), + amount: amt, + }], + )?; + let entry = s.ledger().get_invoice(inv).map(|i| i.entry_id); + if let Some(e) = entry { + let _ = e; + } + open_invoices.push((inv, date, amt)); + } + // Collect invoices 20-50 days out; leave the latest ones open and one overdue. + let mut still_open = Vec::new(); + for (inv, date, amt) in open_invoices.drain(..) { + let paid_on = (date + Days::new(20 + rng.pick(30))).max(day(m, 2)); + let overdue_one = month_index == 12 && inv % 2 == 0; + if paid_on <= today && paid_on < m + Months::new(1) && !overdue_one { + s.receive_payment(inv, paid_on, amt, checking)?; + } else { + still_open.push((inv, date, amt)); + } + } + open_invoices = still_open; + // Template sales, paid out by the platform on the 1st for last month. + if month_index > 1 { + let date = day(m, 1); + if in_range(date) { + let gross = rng.amount(450, 1600); + let fee = (gross * Decimal::new(10, 2)).round_dp(2) + Decimal::new(30, 2); + let net = gross - fee; + let e = s.create_entry(date, "Gumroad payout — template sales")?; + s.add_line(e, checking, Side::Debit, usd(net))?; + s.add_line(e, fees, Side::Debit, usd(fee))?; + s.add_line(e, templates, Side::Credit, usd(gross))?; + for i in 0..3 { + s.set_line_class(e, i, Some(template_class))?; + } + s.attach_receipt( + Some(e), + "gumroad-payout.pdf", + "application/pdf", + &receipt_pdf( + "Gumroad payout", + &[ + format!("Payout {}", date.format("%B %-d, %Y")), + format!("Gross sales: ${gross}"), + format!("Fees: ${fee}"), + format!("TOTAL ${net}"), + "Deposited to checking ending 0187".into(), + ], + ), + None, + )?; + s.post_entry(e)?; + } + } + // Contractors. + if month_index % 2 == 0 && month_index > 0 { + let date = day(m, 28); + if in_range(date) { + let amt = (rng.amount(900, 2200) / dec(25)).round() * dec(25); + post( + &mut s, + date, + "Sam Okafor — illustration (invoice)", + contract, + checking, + amt, + Some(( + "sam-okafor-invoice.pdf", + receipt( + "Sam Okafor Illustration", + date, + "Illustration for client projects", + amt, + "ACH", + ), + )), + Some(sam), + Some(client_class), + )?; + } + } + if month_index == 11 || month_index == 13 { + let date = day(m, 19); + if in_range(date) { + post( + &mut s, + date, + "Riley Chen — front-end development", + contract, + checking, + dec(1400), + Some(( + "riley-chen.pdf", + receipt( + "Riley Chen", + date, + "Website build, milestone", + dec(1400), + "ACH", + ), + )), + Some(riley), + Some(client_class), + )?; + } + } + // Owner draws and savings. + if month_index >= 3 { + let date = day(m, 28); + if in_range(date) { + post( + &mut s, + date, + "Owner draw", + draws, + checking, + dec(3500), + None, + None, + None, + )?; + } + } + if month_index % 3 == 2 { + let date = day(m, 26); + if in_range(date) { + post( + &mut s, + date, + "Transfer to savings", + savings, + checking, + dec(4000), + None, + None, + None, + )?; + } + } + let date = day(m, 30); + if in_range(date) && month_index % 5 == 4 { + post( + &mut s, + date, + "Wire fee", + bank_fees, + checking, + dec(30), + None, + None, + None, + )?; + } + // Month-end: rent occurrences, depreciation, amortization. + let month_end = m + Months::new(1) - Days::new(1); + let through = if current { today } else { month_end }; + for (_, date, entry) in s.run_recurring(through)? { + if current { + continue; // this month's rent waits as a draft + } + s.attach_receipt( + Some(entry), + &format!("rent-{date}.pdf"), + "application/pdf", + &receipt( + "Everett Lofts LLC", + date, + "Studio 4B rent", + dec(1800), + "ACH", + ), + None, + )?; + s.post_entry(entry)?; + s.set_entry_vendor(entry, Some(landlord))?; + } + if !current { + if m >= laptop_date.with_day(1).expect("valid") { + s.run_depreciation(asset, &m.format("%Y-%m").to_string())?; + } + s.run_schedules(month_end)?; + } + // Close each finished calendar year into capital, draws swept in. + if m.month() == 12 && !current { + let ys = NaiveDate::from_ymd_opt(m.year(), 1, 1).expect("valid"); + let p = s.create_period(&m.year().to_string(), ys.max(start), month_end)?; + s.close_period_to( + p, + &CloseTo::Account { + into: capital, + sweep: vec![draws], + }, + )?; + } + m = m + Months::new(1); + month_index += 1; + } + let _ = (bank_fees, client_class); + Ok(()) +} diff --git a/crates/ledger-gui/src/fmt.rs b/crates/ledger-gui/src/fmt.rs new file mode 100644 index 0000000..1116ba5 --- /dev/null +++ b/crates/ledger-gui/src/fmt.rs @@ -0,0 +1,184 @@ +//! Formatting money, dates, and counts. Amounts are exact decimals in and +//! exact strings out: nothing here rounds except `compact`, which says so. + +use chrono::NaiveDate; +use rust_decimal::Decimal; + +/// Thousands separators, two decimals, a true minus sign: `−1,234.56`. +pub fn money(d: Decimal) -> String { + let neg = d.is_sign_negative() && !d.is_zero(); + let s = group(d.abs().round_dp(2)); + if neg { format!("\u{2212}{s}") } else { s } +} + +/// Accounting style: negatives in parentheses, `(1,234.56)`. +pub fn accounting(d: Decimal) -> String { + if d.is_sign_negative() && !d.is_zero() { + format!("({})", group(d.abs().round_dp(2))) + } else { + group(d.round_dp(2)) + } +} + +/// With the currency sign: `$1,234.56`, `−$12.00`. +pub fn dollars(d: Decimal) -> String { + let neg = d.is_sign_negative() && !d.is_zero(); + let s = format!("${}", group(d.abs().round_dp(2))); + if neg { format!("\u{2212}{s}") } else { s } +} + +/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`. +pub fn compact(d: Decimal) -> String { + let f: f64 = d.try_into().unwrap_or(0.0); + let (neg, a) = (f < 0.0, f.abs()); + let s = if a >= 1_000_000.0 { + format!("${:.1}M", a / 1_000_000.0) + } else if a >= 10_000.0 { + format!("${:.0}k", a / 1_000.0) + } else if a >= 1_000.0 { + format!("${:.1}k", a / 1_000.0) + } else { + format!("${a:.0}") + }; + if neg { format!("\u{2212}{s}") } else { s } +} + +fn group(d: Decimal) -> String { + let s = format!("{:.2}", d); + let (int, frac) = s.split_once('.').unwrap_or((&s, "00")); + let mut out = String::new(); + for (i, c) in int.chars().enumerate() { + if i > 0 && (int.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + format!("{out}.{frac}") +} + +/// A percent change from `prev` to `now`, if `prev` isn't zero. +pub fn pct_change(prev: Decimal, now: Decimal) -> Option { + if prev.is_zero() { + return None; + } + let p: f64 = prev.try_into().ok()?; + let n: f64 = now.try_into().ok()?; + Some((n - p) / p.abs() * 100.0) +} + +/// `Oct 12, 2026`. +pub fn date(d: NaiveDate) -> String { + d.format("%b %-d, %Y").to_string() +} + +/// `Oct 12`. +pub fn date_short(d: NaiveDate) -> String { + d.format("%b %-d").to_string() +} + +/// `Oct 2026`. +pub fn month(d: NaiveDate) -> String { + d.format("%b %Y").to_string() +} + +/// `1 receipt`, `3 receipts`. +pub fn count(n: usize, one: &str, many: &str) -> String { + format!("{n} {}", if n == 1 { one } else { many }) +} + +/// An RFC 3339 timestamp from the journal, shown as local-ish UTC text: +/// `Oct 11, 2026 18:04 UTC`. +pub fn timestamp(ts: &str) -> String { + chrono::DateTime::parse_from_rfc3339(ts) + .map(|t| t.format("%b %-d, %Y %H:%M UTC").to_string()) + .unwrap_or_else(|_| ts.to_string()) +} + +/// Bytes as `12.3 KB`. +pub fn bytes(n: u64) -> String { + let f = n as f64; + if f >= 1_048_576.0 { + format!("{:.1} MB", f / 1_048_576.0) + } else if f >= 1024.0 { + format!("{:.1} KB", f / 1024.0) + } else { + format!("{n} B") + } +} + +/// `snake_case_key` as `Snake case key`, for settings shown as labels, +/// keeping acronyms upper case (`ein` → `EIN`). +pub fn label(key: &str) -> String { + const ACRONYMS: [&str; 9] = [ + "ein", "duns", "irs", "cpa", "llc", "tin", "ssn", "id", "url", + ]; + let words: Vec = key + .split(['_', ' ']) + .filter(|w| !w.is_empty()) + .enumerate() + .map(|(i, w)| { + if ACRONYMS.contains(&w.to_lowercase().as_str()) { + w.to_uppercase() + } else if i == 0 { + let mut c = w.chars(); + c.next() + .map(|f| f.to_uppercase().collect::() + c.as_str()) + .unwrap_or_default() + } else { + w.to_string() + } + }) + .collect(); + words.join(" ") +} + +/// A `CamelCase` enum name as words: `PrepaidExpense` → `Prepaid expense`. +pub fn words(debug: &str) -> String { + let mut out = String::new(); + for (i, c) in debug.chars().enumerate() { + if c.is_uppercase() && i > 0 { + out.push(' '); + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} + +/// The tax return a form enum stands for. +pub fn tax_form(f: ledger_core::TaxForm) -> &'static str { + match f { + ledger_core::TaxForm::ScheduleC => "Schedule C (Form 1040)", + ledger_core::TaxForm::Form1065 => "Form 1065 (partnership)", + ledger_core::TaxForm::Form1120S => "Form 1120-S (S corporation)", + ledger_core::TaxForm::Form1120 => "Form 1120 (C corporation)", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn dec(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() + } + + #[test] + fn money_formats() { + assert_eq!(money(dec("1234567.5")), "1,234,567.50"); + assert_eq!(money(dec("-12")), "\u{2212}12.00"); + assert_eq!(money(dec("0")), "0.00"); + assert_eq!(money(dec("-0.00")), "0.00"); + assert_eq!(accounting(dec("-207.68")), "(207.68)"); + assert_eq!(dollars(dec("999.999")), "$1,000.00"); + assert_eq!(compact(dec("1234")), "$1.2k"); + assert_eq!(compact(dec("-25000")), "\u{2212}$25k"); + assert_eq!(compact(dec("950.4")), "$950"); + assert_eq!(label("state_entity_number"), "State entity number"); + assert_eq!(label("ein"), "EIN"); + assert_eq!(label("irs_name_control"), "IRS name control"); + assert_eq!(words("PrepaidExpense"), "Prepaid expense"); + } +} diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs new file mode 100644 index 0000000..9948e48 --- /dev/null +++ b/crates/ledger-gui/src/lib.rs @@ -0,0 +1,21 @@ +//! `ledger-gui`: a read-only, live view of wanderbooks books for the people +//! who own them and the accountant who checks them. See `plan/gui.md`. + +#![allow(missing_docs)] + +pub mod app; +mod attention; +pub mod books; +mod charts; +pub mod data; +pub mod demo; +mod fmt; +pub mod nav; +mod pages; +mod palette; +mod receipts; +mod theme; +mod widgets; + +pub use app::App; +pub use data::Source; diff --git a/crates/ledger-gui/src/main.rs b/crates/ledger-gui/src/main.rs new file mode 100644 index 0000000..58d750a --- /dev/null +++ b/crates/ledger-gui/src/main.rs @@ -0,0 +1,72 @@ +//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]` +//! +//! Opens the books read-only and follows them live. With no flags it looks +//! for `books-data/` and `wanderbooks.toml` in the current directory. +//! `--demo` builds a demo company in a temp directory and opens that. + +use ledger_gui::{App, Source}; +use std::path::PathBuf; + +fn main() -> eframe::Result<()> { + let mut data_dir: Option = None; + let mut books_toml: Option = None; + let mut today = None; + let mut demo = false; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--data-dir" => data_dir = args.next().map(PathBuf::from), + "--books-toml" => books_toml = args.next().map(PathBuf::from), + "--today" => today = args.next().and_then(|d| d.parse().ok()), + "--demo" => demo = true, + "-h" | "--help" => { + println!( + "usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]" + ); + return Ok(()); + } + other => { + eprintln!("unknown argument {other}"); + std::process::exit(2); + } + } + } + if demo { + let d = today.unwrap_or_else(|| chrono::Local::now().date_naive()); + let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}")); + let _ = std::fs::remove_dir_all(&dir); + if let Err(e) = ledger_gui::demo::build(&dir, d) { + eprintln!("building the demo books failed: {e}"); + std::process::exit(1); + } + eprintln!("demo books: {}", dir.display()); + data_dir = Some(dir.join("books-data")); + books_toml = Some(dir.join("wanderbooks.toml")); + } + let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data")); + let books_toml = books_toml.or_else(|| { + let guess = data_dir + .parent() + .unwrap_or(&data_dir) + .join("wanderbooks.toml"); + guess.exists().then_some(guess) + }); + let source = Source { + data_dir, + books_toml, + today, + }; + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("wanderbooks") + .with_app_id("wanderbooks") + .with_inner_size([1440.0, 920.0]) + .with_min_inner_size([900.0, 600.0]), + ..Default::default() + }; + eframe::run_native( + "wanderbooks", + options, + Box::new(move |cc| Ok(Box::new(App::new(cc, source)))), + ) +} diff --git a/crates/ledger-gui/src/nav.rs b/crates/ledger-gui/src/nav.rs new file mode 100644 index 0000000..02b3d34 --- /dev/null +++ b/crates/ledger-gui/src/nav.rs @@ -0,0 +1,195 @@ +//! Where you are and how you got there: a browser-style history. + +use ledger_core::{AccountId, EntryId}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ReportKind { + ProfitAndLoss, + BalanceSheet, + CashFlow, + TrialBalance, + GeneralLedger, + MembersEquity, + TaxSummary, + ArAging, + ApAging, + Vendor1099, +} + +impl ReportKind { + pub const ALL: [ReportKind; 10] = [ + ReportKind::ProfitAndLoss, + ReportKind::BalanceSheet, + ReportKind::CashFlow, + ReportKind::MembersEquity, + ReportKind::TrialBalance, + ReportKind::GeneralLedger, + ReportKind::TaxSummary, + ReportKind::Vendor1099, + ReportKind::ArAging, + ReportKind::ApAging, + ]; + + pub fn title(self) -> &'static str { + match self { + ReportKind::ProfitAndLoss => "Profit and loss", + ReportKind::BalanceSheet => "Balance sheet", + ReportKind::CashFlow => "Cash flow", + ReportKind::TrialBalance => "Trial balance", + ReportKind::GeneralLedger => "General ledger", + ReportKind::MembersEquity => "Owners' equity", + ReportKind::TaxSummary => "Tax summary", + ReportKind::ArAging => "Receivables aging", + ReportKind::ApAging => "Payables aging", + ReportKind::Vendor1099 => "1099 worksheet", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Route { + Overview, + Cash, + Profit, + Transactions, + Entry(EntryId), + Reports(ReportKind), + Accounts, + Account(AccountId), + Documents, + Document(u64), + Company, + Journal, + JournalEvent(u64), +} + +impl Route { + /// The sidebar section this route belongs to. + pub fn section(&self) -> Section { + match self { + Route::Overview => Section::Overview, + Route::Cash => Section::Cash, + Route::Profit => Section::Profit, + Route::Transactions | Route::Entry(_) => Section::Transactions, + Route::Reports(_) => Section::Reports, + Route::Accounts | Route::Account(_) => Section::Accounts, + Route::Documents | Route::Document(_) => Section::Documents, + Route::Company => Section::Company, + Route::Journal | Route::JournalEvent(_) => Section::Journal, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Section { + Overview, + Cash, + Profit, + Transactions, + Reports, + Accounts, + Documents, + Company, + Journal, +} + +impl Section { + pub const ALL: [Section; 9] = [ + Section::Overview, + Section::Cash, + Section::Profit, + Section::Transactions, + Section::Reports, + Section::Accounts, + Section::Documents, + Section::Company, + Section::Journal, + ]; + + pub fn title(self) -> &'static str { + match self { + Section::Overview => "Overview", + Section::Cash => "Cash", + Section::Profit => "Profit", + Section::Transactions => "Transactions", + Section::Reports => "Reports", + Section::Accounts => "Accounts", + Section::Documents => "Documents", + Section::Company => "Company", + Section::Journal => "Journal", + } + } + + pub fn icon(self) -> &'static str { + use egui_phosphor::regular as i; + match self { + Section::Overview => i::SQUARES_FOUR, + Section::Cash => i::BANK, + Section::Profit => i::TREND_UP, + Section::Transactions => i::LIST_BULLETS, + Section::Reports => i::FILE_TEXT, + Section::Accounts => i::TREE_STRUCTURE, + Section::Documents => i::FILES, + Section::Company => i::BUILDINGS, + Section::Journal => i::SCROLL, + } + } + + pub fn home(self) -> Route { + match self { + Section::Overview => Route::Overview, + Section::Cash => Route::Cash, + Section::Profit => Route::Profit, + Section::Transactions => Route::Transactions, + Section::Reports => Route::Reports(ReportKind::ProfitAndLoss), + Section::Accounts => Route::Accounts, + Section::Documents => Route::Documents, + Section::Company => Route::Company, + Section::Journal => Route::Journal, + } + } +} + +/// Back/forward history. +pub struct Nav { + back: Vec, + forward: Vec, + pub current: Route, +} + +impl Nav { + pub fn new(start: Route) -> Self { + Self { + back: Vec::new(), + forward: Vec::new(), + current: start, + } + } + + pub fn go(&mut self, to: Route) { + if to != self.current { + self.back.push(std::mem::replace(&mut self.current, to)); + self.forward.clear(); + } + } + + pub fn can_back(&self) -> bool { + !self.back.is_empty() + } + + pub fn can_forward(&self) -> bool { + !self.forward.is_empty() + } + + pub fn back(&mut self) { + if let Some(r) = self.back.pop() { + self.forward.push(std::mem::replace(&mut self.current, r)); + } + } + + pub fn forward(&mut self) { + if let Some(r) = self.forward.pop() { + self.back.push(std::mem::replace(&mut self.current, r)); + } + } +} diff --git a/crates/ledger-gui/src/pages/accounts.rs b/crates/ledger-gui/src/pages/accounts.rs new file mode 100644 index 0000000..6ab5a0e --- /dev/null +++ b/crates/ledger-gui/src/pages/accounts.rs @@ -0,0 +1,350 @@ +//! The chart of accounts with balances, and one account's register: every +//! posting with a running balance and the balance over time. + +use super::page; +use crate::app::Shell; +use crate::books::Books; +use crate::fmt; +use crate::nav::Route; +use crate::theme::{self, pal}; +use crate::widgets; +use chrono::NaiveDate; +use egui::{CornerRadius, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::AccountType; +use ledger_core::analysis::balance_history; +use ledger_core::reports::general_ledger; +use rust_decimal::Decimal; + +#[derive(Default)] +pub struct State { + /// Limit the register to these dates (set by drill-downs). + pub range: Option<(NaiveDate, NaiveDate)>, + pub show_empty: bool, +} + +pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + page(ui, "accounts", |ui| { + widgets::page_header( + ui, + "Accounts", + "Every account in the chart with today's balance. Click one for its register.", + ); + ui.checkbox( + &mut shell.state.accounts.show_empty, + "Show accounts with no activity", + ); + ui.add_space(10.0); + let used: std::collections::HashSet = b + .entries + .iter() + .flat_map(|e| e.accounts.iter().copied()) + .collect(); + for (t, title) in [ + (AccountType::Asset, "Assets"), + (AccountType::Liability, "Liabilities"), + (AccountType::Equity, "Equity"), + (AccountType::Revenue, "Income"), + (AccountType::Expense, "Expenses"), + ] { + let accounts: Vec<_> = b + .ledger + .chart() + .all() + .filter(|a| { + a.account_type == t && (shell.state.accounts.show_empty || used.contains(&a.id)) + }) + .collect(); + if accounts.is_empty() { + continue; + } + let mut go = None; + widgets::titled_card( + ui, + title, + |_| {}, + |ui| { + for a in &accounts { + let bal = b.balance_on(a.id, b.today); + let (r, resp) = ui + .allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(6), p.hover); + } + let painter = ui.painter_at(r); + painter.text( + r.left_center() + vec2(8.0, 0.0), + egui::Align2::LEFT_CENTER, + &a.code, + theme::regular(13.0), + p.muted, + ); + painter.text( + r.left_center() + vec2(64.0, 0.0), + egui::Align2::LEFT_CENTER, + &a.name, + theme::regular(13.5), + p.text, + ); + let mut tags = Vec::new(); + if b.cash_accounts.contains(&a.id) { + tags.push("cash".to_string()); + } + if let Some(c) = &a.tax_category { + let pct = a + .deductible_percent + .map(|p| format!(" {}%", p.normalize())) + .unwrap_or_default(); + tags.push(format!("tax: {}{pct}", c.replace('_', " "))); + } else if matches!(t, AccountType::Revenue | AccountType::Expense) { + tags.push("no tax category".into()); + } + if a.placeholder { + tags.push("header".into()); + } + painter.text( + r.left_center() + vec2(340.0, 0.0), + egui::Align2::LEFT_CENTER, + tags.join(" · "), + theme::regular(12.0), + p.faint, + ); + painter.text( + r.right_center() - vec2(8.0, 0.0), + egui::Align2::RIGHT_CENTER, + fmt::accounting(bal), + theme::semibold(13.5), + p.text, + ); + if resp.clicked() { + go = Some(a.id); + } + } + }, + ); + if let Some(id) = go { + shell.state.accounts.range = None; + shell.go(Route::Account(id)); + } + ui.add_space(12.0); + } + }); +} + +pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let Some(a) = b.ledger.chart().get(id) else { + return; + }; + let first = b.kpis.first_entry.unwrap_or(b.today); + let (s, e) = shell + .state + .accounts + .range + .unwrap_or((first.min(b.today), b.today)); + page(ui, "account", |ui| { + ui.label( + RichText::new(format!("{:?} account · {}", a.account_type, a.code)) + .font(theme::regular(13.0)) + .color(p.muted), + ); + ui.label(RichText::new(&a.name).font(theme::bold(26.0))); + ui.add_space(4.0); + let bal = b.balance_on(id, b.today); + ui.label( + RichText::new(format!("Balance today {}", fmt::dollars(bal))) + .font(theme::regular(15.0)) + .color(p.muted), + ); + ui.add_space(14.0); + let hist = balance_history(&b.ledger, &[id]); + if hist.len() > 1 && a.account_type.is_balance_sheet() { + widgets::titled_card( + ui, + "Balance over time", + |_| {}, + |ui| crate::charts::balance_line(ui, &hist, b.today, 180.0), + ); + ui.add_space(12.0); + } + ui.horizontal(|ui| { + ui.label( + RichText::new(format!( + "{} {} – {}", + icon::CALENDAR_BLANK, + fmt::date(s), + fmt::date(e) + )) + .font(theme::semibold(13.5)), + ); + if shell.state.accounts.range.is_some() && ui.button("Show all dates").clicked() { + shell.state.accounts.range = None; + } + if ui + .button(format!("{} Find in transactions", icon::LIST_BULLETS)) + .clicked() + { + shell.state.transactions.account = Some(id); + shell.go(Route::Transactions); + } + }); + ui.add_space(8.0); + let Ok(gl) = general_ledger(&b.ledger, s, e) else { + return; + }; + let Some(acct) = gl.accounts.iter().find(|x| x.code == a.code) else { + widgets::card(ui, |ui| { + widgets::empty( + ui, + icon::EMPTY, + "No activity", + "Nothing posted to this account in this range.", + ) + }); + return; + }; + let mut go = None; + widgets::card(ui, |ui| { + let w = ui.available_width(); + let amt = 120.0; + let header = |ui: &mut Ui, vals: [&str; 5]| { + let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover()); + ui.painter().text( + r.left_center() + vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + vals[0], + theme::semibold(12.0), + p.muted, + ); + ui.painter().text( + r.left_center() + vec2(90.0, 0.0), + egui::Align2::LEFT_CENTER, + vals[1], + theme::semibold(12.0), + p.muted, + ); + for (i, v) in vals[2..].iter().enumerate() { + ui.painter().text( + egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, r.center().y), + egui::Align2::RIGHT_CENTER, + *v, + theme::semibold(12.0), + p.muted, + ); + } + }; + header(ui, ["Date", "Description", "Debit", "Credit", "Balance"]); + widgets::rule(ui); + let line = |ui: &mut Ui, + date: String, + desc: String, + sub: String, + d: Decimal, + c: Decimal, + bal: Decimal, + strong: bool| + -> egui::Response { + let (r, resp) = ui.allocate_exact_size( + vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }), + Sense::click(), + ); + if resp.hovered() && !strong { + ui.painter().rect_filled(r, CornerRadius::same(6), p.hover); + } + let painter = ui.painter_at(r); + let font = if strong { + theme::semibold(13.0) + } else { + theme::regular(13.0) + }; + let y = if sub.is_empty() { + r.center().y + } else { + r.center().y - 7.0 + }; + painter.text( + egui::pos2(r.left() + 4.0, y), + egui::Align2::LEFT_CENTER, + date, + theme::regular(12.5), + p.muted, + ); + painter.text( + egui::pos2(r.left() + 90.0, y), + egui::Align2::LEFT_CENTER, + desc, + font.clone(), + p.text, + ); + if !sub.is_empty() { + painter.text( + egui::pos2(r.left() + 90.0, r.center().y + 9.0), + egui::Align2::LEFT_CENTER, + sub, + theme::regular(11.5), + p.faint, + ); + } + for (i, v) in [d, c].iter().enumerate() { + if !v.is_zero() { + painter.text( + egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, y), + egui::Align2::RIGHT_CENTER, + fmt::money(*v), + font.clone(), + p.text, + ); + } + } + painter.text( + egui::pos2(r.right() - 4.0, y), + egui::Align2::RIGHT_CENTER, + fmt::accounting(bal), + font, + p.text, + ); + resp + }; + line( + ui, + fmt::date_short(s), + "Beginning balance".into(), + String::new(), + Decimal::ZERO, + Decimal::ZERO, + acct.beginning_balance, + true, + ); + for post in acct.postings.iter().rev() { + let r = line( + ui, + fmt::date_short(post.date), + post.description.clone(), + format!("#{} · {}", post.entry_id, post.split), + post.debit, + post.credit, + post.balance, + false, + ); + if r.clicked() { + go = Some(post.entry_id); + } + } + widgets::rule(ui); + line( + ui, + fmt::date_short(e), + "Ending balance".into(), + String::new(), + acct.total_debits, + acct.total_credits, + acct.ending_balance, + true, + ); + }); + if let Some(entry) = go { + shell.go(Route::Entry(entry)); + } + }); +} diff --git a/crates/ledger-gui/src/pages/cash.rs b/crates/ledger-gui/src/pages/cash.rs new file mode 100644 index 0000000..ec7988e --- /dev/null +++ b/crates/ledger-gui/src/pages/cash.rs @@ -0,0 +1,457 @@ +//! Cash: what's in the bank, where it's been going, how long it lasts, and +//! what's coming in and out next. + +use super::{columns, page}; +use crate::app::Shell; +use crate::books::Books; +use crate::charts::{self, BarGroup}; +use crate::fmt; +use crate::nav::Route; +use crate::theme::{self, pal}; +use crate::widgets; +use chrono::{Days, NaiveDate}; +use egui::{CornerRadius, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::analysis::{Interval, balance_history, flows_by_interval, interval_start}; +use rust_decimal::Decimal; + +pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + page(ui, "cash", |ui| { + let k = &b.kpis; + let since = b.today - Days::new(30); + let flows = flows_by_interval( + &b.ledger, + &b.cash_accounts, + since + Days::new(1), + b.today, + Interval::Year, + ); + let (cin, cout): (Decimal, Decimal) = + flows.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, f| { + (a.0 + f.2, a.1 + f.3) + }); + widgets::page_header( + ui, + "Cash", + &format!( + "{} across {}. In the last 30 days {} came in and {} went out.", + fmt::dollars(k.cash), + fmt::count(b.cash_accounts.len(), "account", "accounts"), + fmt::dollars(cin), + fmt::dollars(cout) + ), + ); + if b.cash_accounts.is_empty() { + widgets::card(ui, |ui| { + widgets::empty( + ui, + icon::BANK, + "No cash accounts found", + "Name an asset account \"checking\", \"savings\", or \"cash\", or set [books] cash_accounts in wanderbooks.toml.", + ) + }); + return; + } + // One tile per cash and card account. + let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect(); + tiles.extend(b.card_accounts.iter().map(|a| (*a, true))); + let n = tiles.len().clamp(1, 4); + for row in tiles.chunks(n) { + columns(ui, 0.0, &vec![1.0; n], |i, ui| { + let Some((id, is_card)) = row.get(i) else { + return; + }; + let hist = balance_history(&b.ledger, &[*id]); + let spark: Vec = hist + .iter() + .rev() + .take(40) + .rev() + .map(|(_, v)| (*v).try_into().unwrap_or(0.0)) + .collect(); + let bal = b.balance_on(*id, b.today); + let ago = b.balance_on(*id, since); + let delta = bal - ago; + let r = widgets::kpi( + ui, + widgets::Kpi { + label: &b.account_label(*id), + value: fmt::dollars(bal), + sub: Some(( + if *is_card { + "owed on the card".to_string() + } else if delta.is_zero() { + "no change in 30 days".to_string() + } else { + format!( + "{}{} in 30 days", + if delta > Decimal::ZERO { "+" } else { "" }, + fmt::dollars(delta) + ) + }, + if *is_card || delta.is_zero() { + p.muted + } else if delta > Decimal::ZERO { + p.positive + } else { + p.negative + }, + )), + spark: (spark.len() > 1).then_some(&spark[..]), + how: "", + }, + ); + if r.interact(Sense::click()).clicked() { + shell.state.accounts.range = None; + shell.go(Route::Account(*id)); + } + }); + } + let first = k.first_entry.unwrap_or(b.today); + let (start, end) = b.trailing_months(12); + let start = start.max(interval_start(first, Interval::Month)); + columns(ui, 980.0, &[3.0, 2.0], |i, ui| match i { + 0 => { + widgets::titled_card( + ui, + "Cash balance", + |_| {}, + |ui| charts::balance_line(ui, &b.cash_history(), b.today, 240.0), + ); + } + _ => { + let months = + flows_by_interval(&b.ledger, &b.cash_accounts, start, end, Interval::Month); + widgets::titled_card( + ui, + "Money in and out by month", + |_| {}, + |ui| { + let groups: Vec = months + .iter() + .map(|(s, _, i, o)| BarGroup { + label: s.format("%b").to_string(), + values: vec![*i, *o], + }) + .collect(); + charts::bar_chart( + ui, + &groups, + &[("In", p.series[1]), ("Out", p.series[2])], + 210.0, + ); + }, + ); + } + }); + columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i { + 0 => runway(ui, b), + _ => coming_up(ui, b, shell), + }); + biggest_outflows(ui, b, shell); + }); +} + +fn runway(ui: &mut Ui, b: &Books) { + let p = pal(ui.ctx()); + let ms = b.month_start(); + let three = ms - chrono::Months::new(3); + let start = three.max( + b.kpis + .first_entry + .map_or(three, |f| interval_start(f, Interval::Month)), + ); + let months = flows_by_interval( + &b.ledger, + &b.cash_accounts, + start, + ms - Days::new(1), + Interval::Month, + ); + widgets::titled_card( + ui, + "Burn and runway", + |_| {}, + |ui| { + if months.is_empty() { + ui.label(RichText::new("Needs at least one full month of books.").color(p.muted)); + return; + } + let net: Decimal = months.iter().map(|(_, _, i, o)| i - o).sum::() + / Decimal::from(months.len() as u64); + let net = net.round_dp(2); + let big = |ui: &mut Ui, label: &str, value: String, color| { + ui.vertical(|ui| { + ui.label( + RichText::new(label) + .font(theme::regular(12.5)) + .color(p.muted), + ); + ui.label( + RichText::new(value) + .font(theme::semibold(22.0)) + .color(color), + ); + }); + }; + ui.horizontal(|ui| { + big( + ui, + "Average monthly net cash flow", + format!( + "{}{}", + if net > Decimal::ZERO { "+" } else { "" }, + fmt::dollars(net) + ), + if net >= Decimal::ZERO { + p.positive + } else { + p.negative + }, + ); + ui.add_space(32.0); + if net < Decimal::ZERO { + let months_left = (b.kpis.cash / -net).round_dp(1); + let out = b.today + + Days::new((months_left * Decimal::from(30)).try_into().unwrap_or(0u64)); + big(ui, "Runway", format!("{months_left} months"), p.text); + ui.add_space(32.0); + big(ui, "Cash runs out around", fmt::month(out), p.warning); + } else { + big(ui, "Runway", "Cash is growing".into(), p.positive); + } + }); + ui.add_space(8.0); + ui.label( + RichText::new(format!( + "Cash in minus cash out of {}, averaged over the last {} (owner contributions and draws included: this is what the bank sees).", + b.cash_accounts.iter().map(|a| b.account_name(*a)).collect::>().join(" and "), + fmt::count(months.len(), "full month", "full months") + )) + .font(theme::regular(12.0)) + .color(p.faint), + ); + }, + ); +} + +struct Upcoming { + date: NaiveDate, + label: String, + amount: Decimal, + route: Option, +} + +fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let horizon = b.today + Days::new(45); + let mut items: Vec = Vec::new(); + for r in b.ledger.recurring_entries() { + let cash: Decimal = r + .lines + .iter() + .filter(|(a, _, _)| b.cash_accounts.contains(a)) + .map(|(_, s, m)| { + if *s == ledger_core::Side::Debit { + m.amount + } else { + -m.amount + } + }) + .sum(); + for d in r + .due(horizon) + .into_iter() + .filter(|d| *d > b.today - Days::new(1)) + { + items.push(Upcoming { + date: d, + label: format!("{} (recurring)", r.description), + amount: cash, + route: Some(Route::Company), + }); + } + } + for id in 1..b.ledger.next_invoice_id() { + if let Some(inv) = b.ledger.get_invoice(id) + && !inv.balance().is_zero() + { + let who = b + .ledger + .get_customer(inv.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(); + let late = inv.due_date < b.today; + items.push(Upcoming { + date: inv.due_date, + label: format!( + "Invoice {id} from {who}{}", + if late { " — overdue" } else { "" } + ), + amount: inv.balance(), + route: Some(Route::Entry(inv.entry_id)), + }); + } + } + for id in 1..b.ledger.next_bill_id() { + if let Some(bill) = b.ledger.get_bill(id) + && !bill.balance().is_zero() + { + let who = b + .ledger + .get_vendor(bill.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(); + items.push(Upcoming { + date: bill.due_date, + label: format!("Bill {id} to {who}"), + amount: -bill.balance(), + route: Some(Route::Entry(bill.entry_id)), + }); + } + } + items.sort_by_key(|i| i.date); + let mut go = None; + widgets::titled_card( + ui, + "Coming up", + |ui| { + ui.label( + RichText::new("open invoices, bills, recurring") + .font(theme::regular(12.0)) + .color(p.muted), + ); + }, + |ui| { + if items.is_empty() { + widgets::empty( + ui, + icon::CALENDAR_CHECK, + "Nothing scheduled", + "No open invoices, bills, or recurring entries due soon.", + ); + return; + } + let (inflow, outflow): (Decimal, Decimal) = + items.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, i| { + if i.amount > Decimal::ZERO { + (a.0 + i.amount, a.1) + } else { + (a.0, a.1 - i.amount) + } + }); + ui.label( + RichText::new(format!( + "Expected in {} · expected out {}", + fmt::dollars(inflow), + fmt::dollars(outflow) + )) + .font(theme::semibold(13.0)), + ); + ui.add_space(6.0); + egui::ScrollArea::vertical() + .id_salt("upcoming") + .max_height(220.0) + .show(ui, |ui| { + for it in &items { + let (r, resp) = ui + .allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(6), p.hover); + } + let late = it.date < b.today; + ui.painter().text( + r.left_center() + vec2(6.0, 0.0), + egui::Align2::LEFT_CENTER, + fmt::date_short(it.date), + theme::regular(12.5), + if late { p.negative } else { p.muted }, + ); + ui.painter().text( + r.left_center() + vec2(70.0, 0.0), + egui::Align2::LEFT_CENTER, + &it.label, + theme::regular(13.0), + p.text, + ); + let (txt, c) = if it.amount > Decimal::ZERO { + (format!("+{}", fmt::money(it.amount)), p.positive) + } else { + (fmt::money(it.amount), p.text) + }; + ui.painter().text( + r.right_center() - vec2(6.0, 0.0), + egui::Align2::RIGHT_CENTER, + txt, + theme::semibold(13.0), + c, + ); + if resp.clicked() { + go = it.route.clone(); + } + } + }); + }, + ); + if let Some(r) = go { + shell.go(r); + } +} + +fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let since = b.today - Days::new(30); + let mut out: Vec<&crate::books::EntryInfo> = b + .entries + .iter() + .filter(|e| e.date > since && e.cash_effect < Decimal::ZERO) + .collect(); + out.sort_by_key(|e| e.cash_effect); + out.truncate(8); + let mut go = None; + widgets::titled_card( + ui, + "Biggest payments, last 30 days", + |_| {}, + |ui| { + if out.is_empty() { + widgets::empty(ui, icon::ARROW_UP_RIGHT, "No payments", ""); + } + for e in &out { + let (r, resp) = + ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(6), p.hover); + } + ui.painter().text( + r.left_center() + vec2(6.0, 0.0), + egui::Align2::LEFT_CENTER, + fmt::date_short(e.date), + theme::regular(12.5), + p.muted, + ); + ui.painter().text( + r.left_center() + vec2(70.0, 0.0), + egui::Align2::LEFT_CENTER, + &e.description, + theme::regular(13.0), + p.text, + ); + ui.painter().text( + r.right_center() - vec2(6.0, 0.0), + egui::Align2::RIGHT_CENTER, + fmt::money(e.cash_effect), + theme::semibold(13.0), + p.text, + ); + if resp.clicked() { + go = Some(e.id); + } + } + }, + ); + if let Some(id) = go { + shell.go(Route::Entry(id)); + } +} diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs new file mode 100644 index 0000000..06571a8 --- /dev/null +++ b/crates/ledger-gui/src/pages/company.rs @@ -0,0 +1,493 @@ +//! The company: who it is, who owns it, and the standing machinery of the +//! books — periods, fixed assets, amortization schedules, recurring +//! entries, vendors and customers. + +use super::{columns, page}; +use crate::app::Shell; +use crate::books::Books; +use crate::fmt; +use crate::nav::Route; +use crate::theme::{self, pal}; +use crate::widgets; +use egui::{CornerRadius, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::assets::{accumulated_depreciation, book_value}; +use ledger_core::{PeriodStatus, vendor_1099_report}; +use rust_decimal::Decimal; + +fn kv(ui: &mut Ui, k: &str, v: &str) { + let p = pal(ui.ctx()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(180.0, 20.0), Sense::hover()); + ui.painter().text( + r.left_center(), + egui::Align2::LEFT_CENTER, + k, + theme::regular(13.0), + p.muted, + ); + ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap()); + }); +} + +pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + page(ui, "company", |ui| { + let c = &b.settings.company; + widgets::page_header( + ui, + &c.name, + "The company's profile and the standing parts of its books.", + ); + columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i { + 0 => { + widgets::titled_card( + ui, + "Profile", + |_| {}, + |ui| { + if c.profile.is_empty() { + ui.label( + RichText::new("No [company] settings in wanderbooks.toml.") + .color(p.muted), + ); + } + for (k, v) in &c.profile { + kv(ui, &fmt::label(k), v); + } + kv(ui, "Accounting basis", &c.basis); + kv( + ui, + "Tax return", + c.tax_form.map(fmt::tax_form).unwrap_or("not set"), + ); + kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold)); + }, + ); + } + _ => { + widgets::titled_card( + ui, + "Owners", + |_| {}, + |ui| { + if c.members.is_empty() { + // A single owner or a corporation: equity by account. + for a in b + .ledger + .chart() + .all() + .filter(|a| a.account_type == ledger_core::AccountType::Equity) + { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("{} {}", a.code, a.name)) + .font(theme::regular(13.0)), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + RichText::new(fmt::accounting( + b.balance_on(a.id, b.today), + )) + .font(theme::semibold(13.0)), + ); + }, + ); + }); + } + } + for m in &c.members { + let cap = b.ledger.chart().find_by_code(&m.capital_account); + let bal = cap.map(|a| b.balance_on(a.id, b.today)).unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(RichText::new(&m.name).font(theme::semibold(13.5))); + widgets::pill( + ui, + &format!("{}%", (m.share * Decimal::from(100)).normalize()), + p.accent, + p.accent_soft, + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + RichText::new(fmt::dollars(bal)) + .font(theme::semibold(13.5)), + ); + }, + ); + }); + if let Some(a) = cap { + ui.label( + RichText::new(format!("Capital account {} {}", a.code, a.name)) + .font(theme::regular(12.0)) + .color(p.muted), + ); + } + ui.add_space(6.0); + } + }, + ); + ui.add_space(12.0); + widgets::titled_card( + ui, + "Periods", + |_| {}, + |ui| { + let periods = b.ledger.list_periods(); + if periods.is_empty() { + ui.label(RichText::new("No periods yet. Closing a period locks it and moves its earnings into equity.").color(p.muted)); + } + for per in periods { + ui.horizontal(|ui| { + let (g, color) = match per.status { + PeriodStatus::Closed => (icon::LOCK, p.muted), + PeriodStatus::Open => (icon::LOCK_OPEN, p.warning), + }; + ui.label(RichText::new(g).color(color)); + ui.label(RichText::new(&per.name).font(theme::semibold(13.0))); + ui.label( + RichText::new(format!( + "{} – {}", + fmt::date(per.start), + fmt::date(per.end) + )) + .color(p.muted), + ); + if let Some(e) = per.closing_entry + && widgets::link( + ui, + RichText::new(format!("closing entry #{e}")) + .color(p.accent), + ) + .clicked() + { + shell.go(Route::Entry(e)); + } + }); + } + }, + ); + } + }); + assets(ui, b, shell); + schedules(ui, b); + recurring(ui, b); + parties(ui, b); + }); +} + +fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let assets: Vec<_> = b.ledger.registered_assets().collect(); + if assets.is_empty() { + return; + } + let mut go = None; + widgets::titled_card( + ui, + "Fixed assets", + |_| {}, + |ui| { + for a in &assets { + // What has actually been posted, so this ties to the balance + // sheet; the schedule may run ahead of the books. + let acc: Decimal = b + .journal + .iter() + .filter(|l| { + l.line_type == "asset.depreciation_posted" + && l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id) + }) + .filter_map(|l| { + l.payload + .get("amount") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + }) + .sum(); + let bv = a.cost.amount - acc; + let scheduled = accumulated_depreciation(a, b.today); + let _ = book_value; + let dep = a.cost.amount - a.salvage_value.amount; + let frac: f32 = if dep.is_zero() { + 1.0 + } else { + (acc / dep).try_into().unwrap_or(0.0) + }; + ui.horizontal(|ui| { + if widgets::link( + ui, + RichText::new(&a.description).font(theme::semibold(13.5)), + ) + .clicked() + { + go = Some(a.cost_account); + } + ui.label( + RichText::new(format!( + "bought {} · {} months · {}", + fmt::date(a.purchase_date), + a.useful_life_months, + fmt::words(&format!("{:?}", a.status)).to_lowercase() + )) + .font(theme::regular(12.0)) + .color(p.muted), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(format!("book value {}", fmt::dollars(bv))) + .font(theme::semibold(13.0)), + ); + }); + }); + let behind = scheduled - acc; + let note = if behind > Decimal::ZERO { + format!(" · {} more scheduled through today", fmt::dollars(behind)) + } else { + String::new() + }; + progress( + ui, + frac, + &format!( + "{} of {} depreciated{note}", + fmt::dollars(acc), + fmt::dollars(dep) + ), + ); + ui.add_space(8.0); + } + }, + ); + if let Some(id) = go { + shell.go(Route::Account(id)); + } + ui.add_space(16.0); +} + +fn progress(ui: &mut Ui, frac: f32, label: &str) { + let p = pal(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); + let mut done = r; + done.set_width(r.width() * frac.clamp(0.0, 1.0)); + ui.painter() + .rect_filled(done, CornerRadius::same(4), p.accent); + ui.label( + RichText::new(label) + .font(theme::regular(11.5)) + .color(p.faint), + ); +} + +fn schedules(ui: &mut Ui, b: &Books) { + let p = pal(ui.ctx()); + let list: Vec<_> = b.ledger.schedules().collect(); + if list.is_empty() { + return; + } + widgets::titled_card( + ui, + "Amortization schedules", + |_| {}, + |ui| { + for s in list { + let total = s.total.amount; + let remaining = s.remaining(); + let frac: f32 = if total.is_zero() { + 1.0 + } else { + ((total - remaining) / total).try_into().unwrap_or(0.0) + }; + let due = s.due(b.today); + ui.horizontal(|ui| { + ui.label(RichText::new(&s.description).font(theme::semibold(13.5))); + ui.label( + RichText::new(format!( + "{} · {} months from {}", + fmt::words(&format!("{:?}", s.kind)), + s.months, + fmt::month(s.start) + )) + .font(theme::regular(12.0)) + .color(p.muted), + ); + if !due.is_empty() { + widgets::pill(ui, &format!("{} due", due.len()), p.warning, p.warning_soft); + } + }); + progress( + ui, + frac, + &format!( + "{} of {} recognized · {} left", + fmt::dollars(total - remaining), + fmt::dollars(total), + fmt::dollars(remaining) + ), + ); + ui.add_space(8.0); + } + }, + ); + ui.add_space(16.0); +} + +fn recurring(ui: &mut Ui, b: &Books) { + let p = pal(ui.ctx()); + let list: Vec<_> = b.ledger.recurring_entries().collect(); + if list.is_empty() { + return; + } + widgets::titled_card( + ui, + "Recurring entries", + |_| {}, + |ui| { + for r in list { + let amount: Decimal = r + .lines + .iter() + .filter(|(_, s, _)| *s == ledger_core::Side::Debit) + .map(|(_, _, m)| m.amount) + .sum(); + let next = (0..) + .map_while(|k| { + r.frequency + .nth(r.start, k) + .filter(|d| r.end.is_none_or(|e| *d <= e)) + }) + .find(|d| !r.generated.contains(d)); + ui.horizontal(|ui| { + ui.label(RichText::new(&r.description).font(theme::semibold(13.5))); + ui.label( + RichText::new(format!( + "{} · {}", + fmt::words(&format!("{:?}", r.frequency)), + fmt::dollars(amount) + )) + .font(theme::regular(12.0)) + .color(p.muted), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let text = match next { + Some(d) if d <= b.today => format!("due {}", fmt::date(d)), + Some(d) => format!("next {}", fmt::date(d)), + None => "ended".into(), + }; + ui.label(RichText::new(text).font(theme::regular(12.5)).color( + if next.is_some_and(|d| d <= b.today) { + p.warning + } else { + p.muted + }, + )); + }); + }); + ui.label( + RichText::new(format!( + "{} generated so far · drafts only; each one is posted with its receipt", + r.generated.len() + )) + .font(theme::regular(11.5)) + .color(p.faint), + ); + ui.add_space(6.0); + } + }, + ); + ui.add_space(16.0); +} + +fn parties(ui: &mut Ui, b: &Books) { + let p = pal(ui.ctx()); + let year = chrono::Datelike::year(&b.today); + let r1099 = vendor_1099_report(&b.ledger, year, None); + columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i { + 0 => { + widgets::titled_card( + ui, + "Vendors", + |_| {}, + |ui| { + let vendors = b.ledger.list_vendors(); + if vendors.is_empty() { + ui.label(RichText::new("No vendors yet.").color(p.muted)); + } + for v in vendors { + let paid = r1099 + .vendors + .iter() + .find(|x| x.vendor_id == v.id) + .map(|x| x.paid) + .unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(RichText::new(&v.name).font(theme::semibold(13.0))); + if let Some(f) = v.form_1099 { + widgets::pill(ui, f.label(), p.accent, p.accent_soft); + if v.tin_on_file { + widgets::pill(ui, "W-9", p.positive, p.positive_soft); + } else { + widgets::pill(ui, "no W-9", p.negative, p.negative_soft); + } + } + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if !paid.is_zero() { + ui.label( + RichText::new(format!( + "{} paid in {year}", + fmt::dollars(paid) + )) + .font(theme::regular(12.5)) + .color(p.muted), + ); + } + }, + ); + }); + } + }, + ); + } + _ => { + widgets::titled_card( + ui, + "Customers", + |_| {}, + |ui| { + let customers = b.ledger.list_customers(); + if customers.is_empty() { + ui.label(RichText::new("No customers yet.").color(p.muted)); + } + for c in customers { + let owed: Decimal = (1..b.ledger.next_invoice_id()) + .filter_map(|i| b.ledger.get_invoice(i)) + .filter(|i| i.customer_id == c.id) + .map(|i| i.balance()) + .sum(); + ui.horizontal(|ui| { + ui.label(RichText::new(&c.name).font(theme::semibold(13.0))); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if !owed.is_zero() { + ui.label( + RichText::new(format!("owes {}", fmt::dollars(owed))) + .font(theme::regular(12.5)) + .color(p.muted), + ); + } + }, + ); + }); + } + }, + ); + } + }); +} diff --git a/crates/ledger-gui/src/pages/documents.rs b/crates/ledger-gui/src/pages/documents.rs new file mode 100644 index 0000000..e837877 --- /dev/null +++ b/crates/ledger-gui/src/pages/documents.rs @@ -0,0 +1,302 @@ +//! Every receipt and document the books hold, rendered: what it is, what +//! entry it proves, and whether it was withdrawn (and why). + +use super::page; +use crate::app::Shell; +use crate::books::Books; +use crate::fmt; +use crate::nav::Route; +use crate::receipts::State as Render; +use crate::theme::{self, pal}; +use crate::widgets; +use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_store::Attachment; + +#[derive(Default)] +pub struct State { + pub category: Option, + pub show_withdrawn: bool, + pub query: String, +} + +pub fn show(ui: &mut Ui, b: &Books, selected: Option, shell: &mut Shell<'_>) { + match selected.and_then(|id| b.attachments.iter().find(|a| a.id == id)) { + Some(a) => super::split( + ui, + "documents", + (ui.available_width() * 0.5).max(460.0), + shell, + |ui, shell| grid(ui, b, Some(a.id), shell), + |ui, shell| detail(ui, b, a, shell), + ), + None => grid(ui, b, None, shell), + } +} + +fn grid(ui: &mut Ui, b: &Books, selected: Option, shell: &mut Shell<'_>) { + page(ui, "documents", |ui| { + let current = b + .attachments + .iter() + .filter(|a| a.withdrawn_at.is_none()) + .count(); + widgets::page_header( + ui, + "Documents", + &format!( + "{} on file: receipts that prove entries, and the company's records. Bytes are content-addressed and verified on every read.", + fmt::count(current, "document", "documents") + ), + ); + let mut cats: Vec = b.attachments.iter().map(|a| a.category.clone()).collect(); + cats.sort(); + cats.dedup(); + let st = &mut shell.state.documents; + ui.horizontal_wrapped(|ui| { + ui.add( + egui::TextEdit::singleline(&mut st.query) + .hint_text(format!("{} Search file names", icon::MAGNIFYING_GLASS)) + .desired_width(260.0), + ); + if ui.selectable_label(st.category.is_none(), "All").clicked() { + st.category = None; + } + for c in &cats { + if ui + .selectable_label(st.category.as_deref() == Some(c.as_str()), fmt::label(c)) + .clicked() + { + st.category = Some(c.clone()); + } + } + ui.checkbox(&mut st.show_withdrawn, "Show withdrawn"); + }); + ui.add_space(12.0); + let q = st.query.to_lowercase(); + let docs: Vec<&Attachment> = b + .attachments + .iter() + .rev() + .filter(|a| st.show_withdrawn || a.withdrawn_at.is_none()) + .filter(|a| st.category.as_ref().is_none_or(|c| &a.category == c)) + .filter(|a| { + q.is_empty() + || a.file_name.to_lowercase().contains(&q) + || a.description + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&q) + }) + .collect(); + if docs.is_empty() { + widgets::card(ui, |ui| widgets::empty(ui, icon::FILES, "No documents", "")); + return; + } + let tile_w = 200.0; + let per_row = ((ui.available_width() + 16.0) / (tile_w + 16.0)) + .floor() + .max(1.0) as usize; + let mut go = None; + for row in docs.chunks(per_row) { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 16.0; + for a in row { + if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() { + go = Some(a.id); + } + } + }); + ui.add_space(16.0); + } + if let Some(id) = go { + shell.go(Route::Document(id)); + } + }); +} + +fn tile( + ui: &mut Ui, + b: &Books, + a: &Attachment, + w: f32, + on: bool, + shell: &mut Shell<'_>, +) -> egui::Response { + let p = pal(ui.ctx()); + let h = 250.0; + let (rect, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click()); + let fill = if on { p.selected } else { p.surface }; + ui.painter().rect( + rect, + CornerRadius::same(10), + fill, + Stroke::new(1.0, if resp.hovered() { p.accent } else { p.border }), + egui::StrokeKind::Inside, + ); + let thumb = egui::Rect::from_min_size(rect.min + vec2(10.0, 10.0), vec2(w - 20.0, 170.0)); + ui.painter() + .rect_filled(thumb, CornerRadius::same(6), p.hover); + match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) { + Render::Ready(pages) => { + if let Some(tex) = pages.first() { + // Top of the first page, filling the width. + let size = tex.size_vec2(); + let scale = thumb.width() / size.x; + let visible_h = (thumb.height() / scale).min(size.y); + let uv = egui::Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1.0, visible_h / size.y), + ); + let img_rect = + egui::Rect::from_min_size(thumb.min, vec2(thumb.width(), visible_h * scale)); + ui.painter() + .image(tex.id(), img_rect, uv, egui::Color32::WHITE); + } + } + Render::Loading => { + ui.painter().text( + thumb.center(), + egui::Align2::CENTER_CENTER, + "…", + theme::regular(16.0), + p.faint, + ); + } + Render::Failed(_) => { + ui.painter().text( + thumb.center(), + egui::Align2::CENTER_CENTER, + icon::FILE, + egui::FontId::proportional(28.0), + p.faint, + ); + } + } + // Title: what it proves, else what it is. + let title = a + .entry_id + .and_then(|e| b.entry(e)) + .map(|e| e.description.clone()) + .or_else(|| a.description.clone()) + .unwrap_or_else(|| a.file_name.clone()); + let max_chars = ((w - 20.0) / 7.2) as usize; + let title = if title.chars().count() > max_chars { + title.chars().take(max_chars - 1).collect::() + "…" + } else { + title + }; + ui.painter().text( + egui::pos2(rect.left() + 10.0, thumb.bottom() + 18.0), + egui::Align2::LEFT_CENTER, + title, + theme::semibold(12.5), + p.text, + ); + let sub = match (a.entry_id, &a.withdrawn_at) { + (_, Some(_)) => format!("Withdrawn · {}", a.file_name), + (Some(e), None) => match b.entry(e) { + Some(x) => format!( + "#{e} · {} · {}", + fmt::date_short(x.date), + fmt::dollars(x.amount) + ), + None => format!("#{e}"), + }, + (None, None) => format!("{} · {}", fmt::label(&a.category), a.file_name), + }; + let sub = if sub.chars().count() > max_chars + 4 { + sub.chars().take(max_chars + 3).collect::() + "…" + } else { + sub + }; + ui.painter().text( + egui::pos2(rect.left() + 10.0, thumb.bottom() + 40.0), + egui::Align2::LEFT_CENTER, + sub, + theme::regular(11.5), + if a.withdrawn_at.is_some() { + p.warning + } else { + p.muted + }, + ); + resp +} + +fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + egui::ScrollArea::vertical() + .id_salt(("doc", a.id)) + .auto_shrink(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Document {}", a.id)) + .font(theme::regular(12.5)) + .color(p.muted), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false)) + .clicked() + { + shell.go(Route::Documents); + } + }); + }); + ui.label(RichText::new(&a.file_name).font(theme::bold(20.0))); + ui.add_space(8.0); + egui::Grid::new(("docfacts", a.id)) + .num_columns(2) + .spacing(vec2(20.0, 6.0)) + .show(ui, |ui| { + let k = |ui: &mut Ui, s: &str| { + ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted)) + }; + k(ui, "Category"); + ui.label(fmt::label(&a.category)); + ui.end_row(); + k(ui, "Type"); + ui.label(format!("{} · {}", a.mime_type, fmt::bytes(a.size_bytes))); + ui.end_row(); + k(ui, "Filed"); + ui.label(fmt::timestamp(&a.created_at)); + ui.end_row(); + k(ui, "SHA-256"); + ui.add( + egui::Label::new(RichText::new(&a.blob_sha).monospace().size(11.5)).wrap(), + ); + ui.end_row(); + if let Some(e) = a.entry_id { + k(ui, "Proves"); + let label = b + .entry(e) + .map(|x| { + format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount)) + }) + .unwrap_or_else(|| format!("#{e}")); + if widgets::link(ui, RichText::new(label).color(p.accent)).clicked() { + shell.go(Route::Entry(e)); + } + ui.end_row(); + } + }); + ui.add_space(10.0); + super::entry::render(ui, b, a, shell, ui.available_width()); + if let Some(d) = &a.description { + ui.add_space(6.0); + ui.label(RichText::new(d).color(p.muted)); + } + if a.withdrawn_at.is_some() { + ui.add_space(6.0); + widgets::pill( + ui, + &format!("Withdrawn: {}", a.withdrawn_reason.as_deref().unwrap_or("")), + p.warning, + p.warning_soft, + ); + } + }); +} diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs new file mode 100644 index 0000000..e46ef81 --- /dev/null +++ b/crates/ledger-gui/src/pages/entry.rs @@ -0,0 +1,398 @@ +//! One entry, completely: what it is, every line, the receipt itself, and +//! every journal event that touched it. + +use crate::app::Shell; +use crate::books::{Books, EntryInfo}; +use crate::fmt; +use crate::nav::Route; +use crate::receipts::State as Render; +use crate::theme::{self, pal}; +use crate::widgets; +use egui::{CornerRadius, Margin, RichText, Stroke, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::{EntryStatus, Side}; +use ledger_store::Attachment; +use rust_decimal::Decimal; + +pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let Some(entry) = b.ledger.get_entry(e.id) else { + return; + }; + egui::ScrollArea::vertical() + .id_salt(("entry", e.id)) + .auto_shrink(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Entry #{}", e.id)) + .font(theme::regular(12.5)) + .color(p.muted), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false)) + .on_hover_text("Close") + .clicked() + { + shell.go(Route::Transactions); + } + }); + }); + ui.label(RichText::new(&e.description).font(theme::bold(21.0))); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + match e.status { + EntryStatus::Posted => widgets::pill(ui, "Posted", p.positive, p.positive_soft), + EntryStatus::Draft => { + widgets::pill(ui, "Draft — not in any report", p.warning, p.warning_soft) + } + }; + widgets::pill(ui, e.origin, p.muted, p.hover); + if let Some(v) = &e.vendor { + widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover); + } + if e.missing_receipt { + widgets::pill(ui, "Missing receipt", p.negative, p.negative_soft); + } + }); + if let Some(o) = e.reversal_of { + ui.add_space(4.0); + if widgets::link( + ui, + RichText::new(format!("{} Reverses #{o}", icon::ARROW_COUNTER_CLOCKWISE)) + .color(p.accent), + ) + .clicked() + { + shell.go(Route::Entry(o)); + } + } + if let Some(r) = e.reversed_by { + ui.add_space(4.0); + if widgets::link( + ui, + RichText::new(format!( + "{} Reversed by #{r}", + icon::ARROW_COUNTER_CLOCKWISE + )) + .color(p.accent), + ) + .clicked() + { + shell.go(Route::Entry(r)); + } + } + ui.add_space(16.0); + + // Facts. + let created = e.events.first().map(|&i| fmt::timestamp(&b.journal[i].ts)); + // Posted by entry.posted, or in one step by whatever created it + // (a schedule, a close, a bill). + let posted = e + .events + .iter() + .find(|&&i| b.journal[i].line_type == "entry.posted") + .or_else(|| e.events.first()) + .map(|&i| fmt::timestamp(&b.journal[i].ts)); + egui::Grid::new(("facts", e.id)) + .num_columns(2) + .spacing(vec2(24.0, 6.0)) + .show(ui, |ui| { + let k = |ui: &mut Ui, s: &str| { + ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted)) + }; + k(ui, "Date"); + ui.label(fmt::date(e.date)); + ui.end_row(); + k(ui, "Amount"); + ui.label(RichText::new(fmt::dollars(e.amount)).font(theme::semibold(14.0))); + ui.end_row(); + if let Some(c) = &created { + k(ui, "Recorded"); + ui.label(c); + ui.end_row(); + } + if e.status == EntryStatus::Posted + && let Some(t) = &posted + { + k(ui, "Posted"); + ui.label(t); + ui.end_row(); + } + }); + ui.add_space(16.0); + + // Lines. + section(ui, "Lines"); + let total_w = ui.available_width(); + let amt_w = 104.0; + let class_names: std::collections::HashMap = b + .ledger + .list_classes() + .into_iter() + .map(|c| (c.id, c.name.clone())) + .collect(); + egui::Frame::new() + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(10)) + .inner_margin(Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.set_width(total_w - 26.0); + ui.horizontal(|ui| { + let w = ui.available_width() - amt_w * 2.0 - 16.0; + let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), egui::Sense::hover()); + ui.painter().text( + r.left_center(), + egui::Align2::LEFT_CENTER, + "Account", + theme::regular(12.0), + p.muted, + ); + widgets::amount_cell(ui, "Debit", amt_w, p.muted, false, false); + widgets::amount_cell(ui, "Credit", amt_w, p.muted, false, false); + }); + let (mut dr, mut cr) = (Decimal::ZERO, Decimal::ZERO); + for line in entry.lines() { + ui.horizontal(|ui| { + let w = ui.available_width() - amt_w * 2.0 - 16.0; + ui.allocate_ui(vec2(w, 22.0), |ui| { + ui.set_width(w); + ui.horizontal(|ui| { + let r = widgets::link( + ui, + RichText::new(b.account_label(line.account)) + .font(theme::regular(13.5)), + ); + if r.clicked() { + shell.go(Route::Account(line.account)); + } + if let Some(c) = line.class_id.and_then(|c| class_names.get(&c)) + { + ui.label( + RichText::new(format!("{} {c}", icon::TAG)) + .font(theme::regular(11.5)) + .color(p.muted), + ); + } + if let (Some(cur), Some(fa)) = + (&line.foreign_currency, line.foreign_amount) + { + ui.label( + RichText::new(format!( + "{} {}", + fmt::money(fa), + cur.code() + )) + .font(theme::regular(11.5)) + .color(p.muted), + ); + } + }); + }); + let a = fmt::money(line.amount.amount); + let (d, c) = match line.side { + Side::Debit => { + dr += line.amount.amount; + (a, String::new()) + } + Side::Credit => { + cr += line.amount.amount; + (String::new(), a) + } + }; + widgets::amount_cell(ui, &d, amt_w, p.text, false, false); + widgets::amount_cell(ui, &c, amt_w, p.text, false, false); + }); + } + widgets::rule(ui); + ui.horizontal(|ui| { + let w = ui.available_width() - amt_w * 2.0 - 16.0; + let balanced = dr == cr; + let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), egui::Sense::hover()); + ui.painter().text( + r.left_center(), + egui::Align2::LEFT_CENTER, + if balanced { + format!("{} Balanced", icon::CHECK) + } else { + "Out of balance".into() + }, + theme::regular(12.0), + if balanced { p.positive } else { p.negative }, + ); + widgets::amount_cell(ui, &fmt::money(dr), amt_w, p.text, true, false); + widgets::amount_cell(ui, &fmt::money(cr), amt_w, p.text, true, false); + }); + }); + ui.add_space(18.0); + + // Receipts. + let all_for_entry: Vec<&Attachment> = b + .attachments + .iter() + .filter(|a| a.entry_id == Some(e.id)) + .collect(); + section( + ui, + if all_for_entry.len() == 1 { + "Receipt" + } else { + "Receipts" + }, + ); + if all_for_entry.is_empty() { + let msg = if e.missing_receipt { + format!( + "None attached, and this entry is over the {} receipt threshold.", + fmt::dollars(b.receipt_threshold) + ) + } else { + "None attached.".to_string() + }; + ui.label(RichText::new(msg).color(if e.missing_receipt { + p.negative + } else { + p.muted + })); + } + for a in all_for_entry { + attachment_view(ui, b, a, shell, true); + ui.add_space(12.0); + } + ui.add_space(12.0); + + // History. + section(ui, "History"); + for &i in &e.events { + let line = &b.journal[i]; + let ev = super::journal::describe_any(b, line); + ui.horizontal(|ui| { + ui.label(RichText::new(ev.icon).color(p.muted)); + let r = widgets::link(ui, RichText::new(&ev.text).font(theme::regular(12.5))); + if r.clicked() { + shell.go(Route::JournalEvent(line.seq)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(format!("#{} · {}", line.seq, fmt::timestamp(&line.ts))) + .font(theme::regular(11.5)) + .color(p.faint), + ); + }); + }); + } + }); +} + +pub fn section(ui: &mut Ui, title: &str) { + let p = pal(ui.ctx()); + ui.label( + RichText::new(title.to_uppercase()) + .font(theme::semibold(11.5)) + .color(p.faint), + ); + ui.add_space(6.0); +} + +/// A receipt or document: its name and facts, then the file itself. +pub fn attachment_view( + ui: &mut Ui, + b: &Books, + a: &Attachment, + shell: &mut Shell<'_>, + link_to_doc: bool, +) { + let p = pal(ui.ctx()); + ui.horizontal(|ui| { + ui.label( + RichText::new(if a.mime_type == "application/pdf" { + icon::FILE_PDF + } else { + icon::FILE_IMAGE + }) + .size(18.0) + .color(p.muted), + ); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + let name = RichText::new(&a.file_name).font(theme::semibold(13.0)); + if link_to_doc { + if widgets::link(ui, name).clicked() { + shell.go(Route::Document(a.id)); + } + } else { + ui.label(name); + } + ui.label( + RichText::new(format!( + "{} · {} · filed {}", + fmt::label(&a.category), + fmt::bytes(a.size_bytes), + fmt::timestamp(&a.created_at) + )) + .font(theme::regular(11.5)) + .color(p.muted), + ); + }); + }); + if let Some(when) = &a.withdrawn_at { + widgets::pill( + ui, + &format!( + "Withdrawn {}: {}", + fmt::timestamp(when), + a.withdrawn_reason.as_deref().unwrap_or("") + ), + p.warning, + p.warning_soft, + ); + } + if let Some(d) = &a.description { + ui.label(RichText::new(d).font(theme::regular(12.5)).color(p.muted)); + } + ui.add_space(6.0); + render(ui, b, a, shell, ui.available_width()); +} + +/// The file itself, pages stacked, scaled to `width`. +pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, width: f32) { + let p = pal(ui.ctx()); + match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) { + Render::Loading => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new("Rendering…").color(p.muted)); + }); + } + Render::Failed(err) => { + ui.label( + RichText::new(format!("{} Can't show this file: {err}", icon::WARNING)) + .color(p.negative), + ); + } + Render::Ready(pages) => { + for (i, tex) in pages.iter().enumerate() { + let size = tex.size_vec2(); + let scale = (width / size.x).min(1.0); + let img = egui::Image::new(tex) + .fit_to_exact_size(size * scale) + .corner_radius(CornerRadius::same(4)); + egui::Frame::new() + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(6)) + .show(ui, |ui| { + ui.add(img); + }); + if pages.len() > 1 { + ui.label( + RichText::new(format!("Page {} of {}", i + 1, pages.len())) + .font(theme::regular(11.0)) + .color(p.faint), + ); + } + ui.add_space(6.0); + } + } + } +} diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs new file mode 100644 index 0000000..5da4534 --- /dev/null +++ b/crates/ledger-gui/src/pages/journal.rs @@ -0,0 +1,482 @@ +//! The journal: every event, in order, as recorded. The audit trail an +//! accountant wants and the raw truth everything else is built from. + +use super::page; +use crate::app::Shell; +use crate::books::{Books, entry_refs}; +use crate::fmt; +use crate::nav::Route; +use crate::theme::{self, pal}; +use crate::widgets; +use egui::{CornerRadius, Margin, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_store::JournalLine; +use serde_json::Value; + +#[derive(Default)] +pub struct State { + pub query: String, + pub hide_steps: bool, +} + +pub struct Event { + pub icon: &'static str, + pub text: String, + pub route: Option, +} + +fn s<'a>(p: &'a Value, k: &str) -> &'a str { + p.get(k).and_then(|v| v.as_str()).unwrap_or("") +} + +fn amount(p: &Value) -> String { + p.get("amount") + .and_then(|v| v.as_str()) + .and_then(|a| a.parse().ok()) + .map(fmt::dollars) + .unwrap_or_default() +} + +fn entry_text(b: &Books, id: u64) -> String { + b.entry(id) + .map(|e| format!("#{} {} ({})", e.id, e.description, fmt::dollars(e.amount))) + .unwrap_or_else(|| format!("#{id}")) +} + +fn attachment_name(b: &Books, id: u64) -> String { + b.attachments + .iter() + .find(|a| a.id == id) + .map(|a| a.file_name.clone()) + .unwrap_or_else(|| format!("attachment {id}")) +} + +/// A sentence for a meaningful event; `None` for the intermediate steps of +/// building an entry (created, line added), which the posted event covers. +pub fn describe(b: &Books, line: &JournalLine) -> Option { + let p = &line.payload; + let id = p.get("id").and_then(|v| v.as_u64()).unwrap_or(0); + let entry_id = p.get("entry_id").and_then(|v| v.as_u64()); + let att = p.get("attachment_id").and_then(|v| v.as_u64()).unwrap_or(0); + let ev = |icon, text: String, route: Option| Some(Event { icon, text, route }); + match line.line_type.as_str() { + "entry.created" | "entry.line_added" | "entry.line_class_set" => None, + "store.created" => ev(icon::BOOK_OPEN, "Books created".into(), None), + "entry.posted" => ev( + icon::CHECK_CIRCLE, + format!("Posted {}", entry_text(b, id)), + Some(Route::Entry(id)), + ), + "entry.reversed" => ev( + icon::ARROW_COUNTER_CLOCKWISE, + format!( + "Reversed #{} with #{id}", + p.get("reverses").and_then(|v| v.as_u64()).unwrap_or(0) + ), + Some(Route::Entry(id)), + ), + "receipt.attached" => ev( + icon::PAPERCLIP, + match entry_id { + Some(e) => format!("Attached {} to #{e}", s(p, "file_name")), + None => format!("Filed receipt {}", s(p, "file_name")), + }, + Some(Route::Document(att)), + ), + "document.filed" => ev( + icon::FILE_ARROW_UP, + format!( + "Filed {} ({})", + s(p, "file_name"), + fmt::label(s(p, "category")) + ), + Some(Route::Document(att)), + ), + "receipt.linked" => ev( + icon::LINK, + format!( + "Linked {} to #{}", + attachment_name(b, att), + entry_id.unwrap_or(0) + ), + entry_id.map(Route::Entry), + ), + "receipt.unlinked" => ev( + icon::LINK_BREAK, + format!( + "Unlinked {} from #{}: {}", + attachment_name(b, att), + entry_id.unwrap_or(0), + s(p, "reason") + ), + entry_id.map(Route::Entry), + ), + "attachment.withdrawn" => ev( + icon::ARCHIVE, + format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")), + Some(Route::Document(att)), + ), + "account.added" => ev( + icon::PLUS_CIRCLE, + format!("Added account {} {}", s(p, "code"), s(p, "name")), + Some(Route::Account(id)), + ), + "account.tax_set" => ev( + icon::TAG, + format!( + "Tax category of {}: {}", + b.account_label(p.get("account_id").and_then(|v| v.as_u64()).unwrap_or(0)), + p.get("tax_category") + .and_then(|v| v.as_str()) + .unwrap_or("none") + ), + p.get("account_id") + .and_then(|v| v.as_u64()) + .map(Route::Account), + ), + "vendor.added" => ev( + icon::STOREFRONT, + format!("Added vendor {}", s(p, "name")), + Some(Route::Company), + ), + "customer.added" => ev( + icon::USER_PLUS, + format!("Added customer {}", s(p, "name")), + Some(Route::Company), + ), + "class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None), + "vendor.1099_set" => ev( + icon::IDENTIFICATION_CARD, + "Updated a vendor's 1099 details".into(), + Some(Route::Company), + ), + "entry.vendor_set" => ev( + icon::STOREFRONT, + format!("Tagged #{} with a vendor", entry_id.unwrap_or(0)), + entry_id.map(Route::Entry), + ), + "invoice.created" => ev( + icon::INVOICE, + format!("Invoice {id} created"), + entry_id.map(Route::Entry), + ), + "bill.created" => ev( + icon::RECEIPT, + format!("Bill {id} recorded"), + entry_id.map(Route::Entry), + ), + "invoice.payment_received" => ev( + icon::HAND_COINS, + format!("Payment received, {}", amount(p)), + entry_id.map(Route::Entry), + ), + "bill.paid" => ev( + icon::MONEY, + format!("Bill paid, {}", amount(p)), + entry_id.map(Route::Entry), + ), + "period.created" => ev( + icon::CALENDAR_PLUS, + format!("Period {} created", s(p, "name")), + Some(Route::Company), + ), + "period.closed" => ev( + icon::LOCK, + "Period closed".into(), + p.get("closing_entry") + .and_then(|v| v.as_u64()) + .map(Route::Entry), + ), + "asset.registered" => ev( + icon::PACKAGE, + format!("Registered asset {}", s(p, "description")), + Some(Route::Company), + ), + "asset.depreciation_posted" => ev( + icon::TREND_DOWN, + "Depreciation posted".into(), + entry_id.map(Route::Entry), + ), + "asset.disposed" => ev( + icon::TRASH, + "Asset disposed".into(), + entry_id.map(Route::Entry), + ), + "schedule.registered" => ev( + icon::CALENDAR, + format!("Schedule: {}", s(p, "description")), + Some(Route::Company), + ), + "schedule.posted" => ev( + icon::CALENDAR_CHECK, + "Amortization posted".into(), + p.get("entry") + .and_then(|e| e.get("id")) + .and_then(|v| v.as_u64()) + .map(Route::Entry), + ), + "recurring.registered" => ev( + icon::REPEAT, + format!("Recurring: {}", s(p, "description")), + Some(Route::Company), + ), + "recurring.generated" => ev( + icon::REPEAT, + "Recurring draft created".into(), + entry_id.map(Route::Entry), + ), + "fx.rate_set" => ev( + icon::CURRENCY_CIRCLE_DOLLAR, + format!("{} rate set", s(p, "currency")), + None, + ), + "payroll.run" => ev( + icon::USERS, + "Payroll run".into(), + entry_id.map(Route::Entry), + ), + "line.cleared" => ev( + icon::CHECK_SQUARE, + format!("Cleared a line of #{}", entry_id.unwrap_or(0)), + entry_id.map(Route::Entry), + ), + other => ev( + icon::DOT, + other.to_string(), + entry_refs(line).first().copied().map(Route::Entry), + ), + } +} + +/// Like [`describe`], but every event gets a sentence. +pub fn describe_any(b: &Books, line: &JournalLine) -> Event { + if let Some(e) = describe(b, line) { + return e; + } + let p = &line.payload; + let id = p + .get("id") + .and_then(|v| v.as_u64()) + .or_else(|| p.get("entry_id").and_then(|v| v.as_u64())) + .unwrap_or(0); + match line.line_type.as_str() { + "entry.created" => Event { + icon: icon::PENCIL_SIMPLE, + text: format!("Drafted #{id} {}", s(p, "description")), + route: Some(Route::Entry(id)), + }, + "entry.line_added" => { + let amount = p + .get("amount") + .and_then(|a| a.get("amount").or(Some(a))) + .and_then(|v| v.as_str()) + .and_then(|v| v.parse().ok()) + .map(fmt::money) + .unwrap_or_default(); + Event { + icon: icon::PLUS, + text: format!( + "Line on #{id}: {} {} {amount}", + s(p, "side"), + b.account_label( + p.get("account_id") + .or_else(|| p.get("account")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) + ), + ), + route: Some(Route::Entry(id)), + } + } + "entry.line_class_set" => { + let class = p + .get("class_id") + .and_then(|v| v.as_u64()) + .and_then(|c| b.ledger.get_class(c as u32)) + .map(|c| c.name.clone()) + .unwrap_or_else(|| "no class".into()); + Event { + icon: icon::TAG, + text: format!( + "Line {} of #{id} tagged {class}", + p.get("line_index") + .and_then(|v| v.as_u64()) + .map_or(0, |i| i + 1) + ), + route: Some(Route::Entry(id)), + } + } + t => Event { + icon: icon::DOT, + text: t.to_string(), + route: None, + }, + } +} + +pub fn show(ui: &mut Ui, b: &Books, selected: Option, shell: &mut Shell<'_>) { + if let Some(seq) = selected + && let Some(line) = b.journal.get(seq.saturating_sub(1) as usize) + { + super::split( + ui, + "journal", + 480.0, + shell, + |ui, shell| list(ui, b, selected, shell), + |ui, shell| detail(ui, b, line, shell), + ); + } else { + list(ui, b, None, shell); + } +} + +fn list(ui: &mut Ui, b: &Books, selected: Option, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + page(ui, "journal", |ui| { + widgets::page_header( + ui, + "Journal", + &format!( + "Every change to the books, in order: {} events, each hash-chained to the one before. Nothing is ever edited or deleted; corrections are new events.", + b.journal.len() + ), + ); + let st = &mut shell.state.journal; + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut st.query) + .hint_text(format!("{} Filter events", icon::MAGNIFYING_GLASS)) + .desired_width(320.0), + ); + ui.checkbox(&mut st.hide_steps, "Hide entry-building steps"); + }); + ui.add_space(10.0); + let q = st.query.to_lowercase(); + let hide = st.hide_steps; + let rows: Vec<(&JournalLine, Event)> = b + .journal + .iter() + .rev() + .filter(|l| !(hide && describe(b, l).is_none())) + .map(|l| (l, describe_any(b, l))) + .filter(|(l, e)| { + q.is_empty() || e.text.to_lowercase().contains(&q) || l.line_type.contains(&q) + }) + .collect(); + widgets::card(ui, |ui| { + let row_h = 34.0; + egui::ScrollArea::vertical() + .id_salt("journal_rows") + .max_height(ui.available_height().max(400.0)) + .show_rows(ui, row_h, rows.len(), |ui, range| { + for (line, ev) in &rows[range] { + let (rect, resp) = ui + .allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); + let on = selected == Some(line.seq); + let fresh = line.seq > b.fresh_from_seq && shell.since_update < 8.0; + if on { + ui.painter() + .rect_filled(rect, CornerRadius::same(6), p.selected); + } else if fresh { + ui.painter() + .rect_filled(rect, CornerRadius::same(6), p.new_highlight); + } else if resp.hovered() { + ui.painter() + .rect_filled(rect, CornerRadius::same(6), p.hover); + } + let painter = ui.painter_at(rect); + let y = rect.center().y; + painter.text( + egui::pos2(rect.left() + 8.0, y), + egui::Align2::LEFT_CENTER, + format!("{}", line.seq), + theme::regular(12.0), + p.faint, + ); + painter.text( + egui::pos2(rect.left() + 56.0, y), + egui::Align2::LEFT_CENTER, + ev.icon, + egui::FontId::proportional(15.0), + p.muted, + ); + painter.text( + egui::pos2(rect.left() + 80.0, y), + egui::Align2::LEFT_CENTER, + &ev.text, + theme::regular(13.0), + p.text, + ); + painter.text( + egui::pos2(rect.right() - 8.0, y), + egui::Align2::RIGHT_CENTER, + fmt::timestamp(&line.ts), + theme::regular(12.0), + p.faint, + ); + if resp.clicked() { + shell.go(Route::JournalEvent(line.seq)); + } + } + }); + }); + }); +} + +fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let ev = describe_any(b, line); + egui::ScrollArea::vertical() + .id_salt("event_detail") + .show(ui, |ui| { + ui.label( + RichText::new(format!("Event {}", line.seq)) + .font(theme::regular(12.5)) + .color(p.muted), + ); + ui.label(RichText::new(&ev.text).font(theme::semibold(17.0))); + ui.add_space(10.0); + let fact = |ui: &mut Ui, k: &str, v: &str| { + ui.horizontal(|ui| { + ui.add_sized( + vec2(90.0, 18.0), + egui::Label::new( + RichText::new(k).font(theme::regular(12.5)).color(p.muted), + ), + ); + ui.add(egui::Label::new(RichText::new(v).font(theme::regular(12.5))).wrap()); + }); + }; + fact(ui, "Type", &line.line_type); + fact(ui, "Recorded", &fmt::timestamp(&line.ts)); + fact(ui, "Previous", &line.prev); + let own = ledger_store::journal::sha256_hex(line.to_canonical().as_bytes()); + fact(ui, "This hash", &own); + if let Some(r) = &ev.route { + ui.add_space(6.0); + if ui + .button(format!( + "{} Open {}", + icon::ARROW_SQUARE_OUT, + crate::palette::describe(b, r) + )) + .clicked() + { + shell.go(r.clone()); + } + } + ui.add_space(12.0); + ui.label(RichText::new("Payload").font(theme::semibold(13.0))); + ui.add_space(4.0); + let json = serde_json::to_string_pretty(&line.payload).unwrap_or_default(); + egui::Frame::new() + .fill(p.hover) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::same(10)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.add(egui::Label::new(RichText::new(json).monospace().size(12.0)).wrap()); + }); + }); +} diff --git a/crates/ledger-gui/src/pages/mod.rs b/crates/ledger-gui/src/pages/mod.rs new file mode 100644 index 0000000..828c6be --- /dev/null +++ b/crates/ledger-gui/src/pages/mod.rs @@ -0,0 +1,156 @@ +//! One module per screen. + +pub mod accounts; +pub mod cash; +pub mod company; +pub mod documents; +pub mod entry; +pub mod journal; +pub mod overview; +pub mod profit; +pub mod reports; +pub mod transactions; + +use crate::app::Shell; +use crate::books::Books; +use crate::nav::Route; +use egui::{Margin, Ui}; + +/// Standard page body: scrollable, padded. +pub fn page(ui: &mut Ui, id: &str, add: impl FnOnce(&mut Ui)) { + egui::ScrollArea::vertical() + .id_salt(id) + .auto_shrink(false) + .show(ui, |ui| { + egui::Frame::new() + .inner_margin(Margin { + left: 32, + right: 32, + top: 24, + bottom: 40, + }) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + add(ui) + }); + }); +} + +pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) { + match route { + Route::Overview => overview::show(ui, b, shell), + Route::Cash => cash::show(ui, b, shell), + Route::Profit => profit::show(ui, b, shell), + Route::Transactions => transactions::show(ui, b, None, shell), + Route::Entry(id) => transactions::show(ui, b, Some(*id), shell), + Route::Reports(kind) => reports::show(ui, b, *kind, shell), + Route::Accounts => accounts::show_chart(ui, b, shell), + Route::Account(id) => accounts::show_account(ui, b, *id, shell), + Route::Documents => documents::show(ui, b, None, shell), + Route::Document(id) => documents::show(ui, b, Some(*id), shell), + Route::Company => company::show(ui, b, shell), + Route::Journal => journal::show(ui, b, None, shell), + Route::JournalEvent(seq) => journal::show(ui, b, Some(*seq), shell), + } +} + +/// Columns sized by `weights` when the page is at least `min_width` +/// wide; stacked otherwise. `add(i, ui)` fills column `i`. +pub fn columns(ui: &mut Ui, min_width: f32, weights: &[f32], mut add: impl FnMut(usize, &mut Ui)) { + let gap = 16.0; + if ui.available_width() < min_width { + for i in 0..weights.len() { + add(i, ui); + ui.add_space(gap); + } + return; + } + let total: f32 = weights.iter().sum(); + let avail = ui.available_width() - gap * (weights.len() as f32 - 1.0); + let top = ui.cursor().min; + let mut x = top.x; + let mut bottom = top.y; + for (i, w) in weights.iter().enumerate() { + let width = avail * w / total; + let rect = + egui::Rect::from_min_size(egui::pos2(x, top.y), egui::vec2(width, f32::INFINITY)); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + child.set_width(width); + add(i, &mut child); + bottom = bottom.max(child.min_rect().bottom()); + x += width + gap; + } + let used = egui::Rect::from_min_max(top, egui::pos2(top.x + ui.available_width(), bottom)); + ui.advance_cursor_after_rect(used); + ui.add_space(gap); +} + +/// A list on the left and a detail pane on the right, split by a +/// draggable divider. The split is remembered per `id`. +pub fn split( + ui: &mut Ui, + id: &str, + default_detail: f32, + state: &mut T, + left: impl FnOnce(&mut Ui, &mut T), + right: impl FnOnce(&mut Ui, &mut T), +) { + let p = crate::theme::pal(ui.ctx()); + let rect = ui.available_rect_before_wrap(); + let key = egui::Id::new(("split", id)); + let mut detail_w: f32 = ui + .ctx() + .data_mut(|d| d.get_persisted(key)) + .unwrap_or(default_detail); + detail_w = detail_w.clamp(360.0, (rect.width() - 380.0).max(360.0)); + let divider_x = rect.right() - detail_w; + let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(divider_x, rect.bottom())); + let right_rect = egui::Rect::from_min_max(egui::pos2(divider_x + 1.0, rect.top()), rect.max); + ui.painter().rect_filled(right_rect, 0.0, p.surface); + ui.painter().line_segment( + [ + egui::pos2(divider_x, rect.top()), + egui::pos2(divider_x, rect.bottom()), + ], + egui::Stroke::new(1.0, p.border), + ); + let mut l = ui.new_child( + egui::UiBuilder::new() + .max_rect(left_rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + l.set_clip_rect(left_rect); + left(&mut l, state); + let inner = right_rect.shrink2(egui::vec2(24.0, 20.0)); + let mut r = ui.new_child( + egui::UiBuilder::new() + .max_rect(inner) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + r.set_clip_rect(right_rect); + right(&mut r, state); + let handle = egui::Rect::from_center_size( + egui::pos2(divider_x, rect.center().y), + egui::vec2(8.0, rect.height()), + ); + let resp = ui.interact(handle, key.with("handle"), egui::Sense::drag()); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + ui.painter().line_segment( + [ + egui::pos2(divider_x, rect.top()), + egui::pos2(divider_x, rect.bottom()), + ], + egui::Stroke::new(2.0, p.accent), + ); + } + if resp.dragged() { + detail_w -= resp.drag_delta().x; + ui.ctx().data_mut(|d| d.insert_persisted(key, detail_w)); + } + ui.allocate_rect(rect, egui::Sense::hover()); +} diff --git a/crates/ledger-gui/src/pages/overview.rs b/crates/ledger-gui/src/pages/overview.rs new file mode 100644 index 0000000..fbf8a91 --- /dev/null +++ b/crates/ledger-gui/src/pages/overview.rs @@ -0,0 +1,481 @@ +//! Home: the state of the business in one sentence, the numbers behind +//! it, what needs doing, and what just happened. + +use super::{columns, page}; +use crate::app::Shell; +use crate::books::{Books, Severity}; +use crate::charts::{self, BarGroup}; +use crate::fmt; +use crate::nav::{ReportKind, Route}; +use crate::theme::{self, pal}; +use crate::widgets::{self, Kpi}; +use egui::{RichText, Ui}; +use egui_phosphor::regular as icon; +use ledger_core::analysis::{Interval, pnl_by_interval}; +use ledger_core::reports::profit_and_loss_for_period; +use rust_decimal::Decimal; + +pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + page(ui, "overview", |ui| { + let k = &b.kpis; + widgets::page_header(ui, b.company_name(), &headline(b)); + kpis(ui, b, shell); + let _ = k; + columns(ui, 980.0, &[2.0, 1.0], |i, ui| match i { + 0 => { + let (more, _) = widgets::titled_card( + ui, + "Cash over time", + |ui| { + widgets::link( + ui, + RichText::new("Cash details") + .font(theme::regular(12.5)) + .color(pal(ui.ctx()).accent), + ) + .clicked() + }, + |ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0), + ); + if more { + shell.go(Route::Cash); + } + } + _ => attention(ui, b, shell), + }); + columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i { + 0 => income_vs_spending(ui, b, shell), + _ => where_it_went(ui, b, shell), + }); + recent(ui, b, shell); + }); +} + +/// The answer, in words. +fn headline(b: &Books) -> String { + let k = &b.kpis; + let Some(first) = k.first_entry else { + return "No transactions yet. Once your agent records some, this page fills in on its own." + .into(); + }; + let mut s = format!( + "You have {} in cash and owe {}.", + fmt::dollars(k.cash), + fmt::dollars(k.liabilities) + ); + if let Some(burn) = k.monthly_burn { + if burn > Decimal::ZERO && k.cash > Decimal::ZERO { + let months = (k.cash / burn).round_dp(1); + s.push_str(&format!( + " Lately you spend {} a month more than you earn: about {} months of runway.", + fmt::dollars(burn), + months + )); + } + } else if k.net_ytd > Decimal::ZERO { + s.push_str(&format!( + " You've earned {} more than you've spent this year.", + fmt::dollars(k.net_ytd) + )); + } else if k.net_ytd < Decimal::ZERO { + s.push_str(&format!( + " You've spent {} more than you've earned this year.", + fmt::dollars(-k.net_ytd) + )); + } + if (b.today - first).num_days() < 60 { + s.push_str(&format!( + " Your books start {}; trends fill in as the months go by.", + fmt::date(first) + )); + } + s +} + +fn change( + now: Decimal, + prev: Decimal, + up_is_good: bool, + what: &str, +) -> Option<(String, egui::Color32)> { + let pct = fmt::pct_change(prev, now)?; + let good = (pct >= 0.0) == up_is_good; + let p = &theme::LIGHT; + let arrow = if pct >= 0.0 { "▲" } else { "▼" }; + Some(( + format!("{arrow} {:.0}% vs {what}", pct.abs()), + if pct.abs() < 0.5 { + p.muted + } else if good { + p.positive + } else { + p.negative + }, + )) +} + +fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let k = &b.kpis; + let p = pal(ui.ctx()); + // Last 12 months of month-end cash for the sparkline. + let (start, end) = b.trailing_months(12); + let start = k.first_entry.map_or(start, |f| { + start.max(ledger_core::analysis::interval_start(f, Interval::Month)) + }); + let month_ends: Vec = + ledger_core::analysis::intervals(start, end, Interval::Month) + .into_iter() + .map(|(_, e)| e) + .collect(); + let cash_spark: Vec = + ledger_core::analysis::balance_series(&b.ledger, &b.cash_accounts, &month_ends) + .into_iter() + .map(|d| d.try_into().unwrap_or(0.0)) + .collect(); + let runway = match k.monthly_burn { + Some(burn) if burn > Decimal::ZERO && k.cash > Decimal::ZERO => ( + format!("{} mo", (k.cash / burn).round_dp(1)), + Some((format!("burning {}/mo", fmt::compact(burn)), p.warning)), + ), + _ if k.first_entry.is_none() => ("—".to_string(), None), + _ if k.first_entry.is_some_and(|f| f >= b.month_start()) => ( + "Too early".to_string(), + Some(("needs a full month of books".to_string(), p.muted)), + ), + _ => ( + "Not burning".to_string(), + Some(("earning ≥ spending".to_string(), p.positive)), + ), + }; + let ytd = b.fiscal_ytd(); + let tiles: Vec<(Kpi, Route)> = vec![ + ( + Kpi { + label: "Cash on hand", + value: fmt::dollars(k.cash), + sub: change(k.cash, k.cash_30d_ago, true, "30 days ago") + .map(|(t, c)| (t, color_for(c, p))), + spark: Some(&cash_spark), + how: "The balance of every cash account (bank, savings, cash on hand) at the end of today.", + }, + Route::Cash, + ), + ( + Kpi { + label: "Owed by the business", + value: fmt::dollars(k.liabilities), + sub: Some(("bills, cards, owners, taxes".into(), p.muted)), + spark: None, + how: "Total liabilities on today's balance sheet.", + }, + Route::Reports(ReportKind::BalanceSheet), + ), + ( + Kpi { + label: "Spending this month", + value: fmt::dollars(k.expenses_mtd), + sub: change( + k.expenses_mtd, + k.expenses_prev_month, + false, + "same days last month", + ) + .map(|(t, c)| (t, color_for(c, p))), + spark: None, + how: "Expenses dated this month (accrual basis: when incurred, not when paid).", + }, + Route::Profit, + ), + ( + Kpi { + label: "Revenue this month", + value: fmt::dollars(k.revenue_mtd), + sub: change( + k.revenue_mtd, + k.revenue_prev_month, + true, + "same days last month", + ) + .map(|(t, c)| (t, color_for(c, p))), + spark: None, + how: "Revenue dated this month.", + }, + Route::Profit, + ), + ( + Kpi { + label: "Net income, year to date", + value: fmt::dollars(k.net_ytd), + sub: Some((format!("since {}", fmt::date_short(ytd.0)), p.muted)), + spark: None, + how: "Revenue minus expenses for the fiscal year so far.", + }, + Route::Reports(ReportKind::ProfitAndLoss), + ), + ( + Kpi { + label: "Runway", + value: runway.0, + sub: runway.1, + spark: None, + how: "Cash on hand divided by the average monthly net loss over the last three full months the books cover.", + }, + Route::Cash, + ), + ]; + let n = if ui.available_width() > 1180.0 { 6 } else { 3 }; + for row in tiles.chunks(n) { + let weights = vec![1.0; n]; + columns(ui, 0.0, &weights, |i, ui| { + if let Some((tile, route)) = row.get(i) { + let r = widgets::kpi( + ui, + Kpi { + label: tile.label, + value: tile.value.clone(), + sub: tile.sub.clone(), + spark: tile.spark, + how: tile.how, + }, + ); + if r.interact(egui::Sense::click()).clicked() { + shell.go(route.clone()); + } + } + }); + } +} + +/// `change` picks from the light palette; map to the current one. +fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 { + let l = &theme::LIGHT; + if c == l.positive { + p.positive + } else if c == l.negative { + p.negative + } else { + p.muted + } +} + +fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "Needs attention", + |ui| { + if !b.attention.is_empty() { + widgets::pill( + ui, + &b.attention.len().to_string(), + p.warning, + p.warning_soft, + ); + } + }, + |ui| { + if b.attention.is_empty() { + widgets::empty( + ui, + icon::CHECK_CIRCLE, + "All clear", + "Nothing is missing or overdue.", + ); + return; + } + egui::ScrollArea::vertical() + .id_salt("attention") + .max_height(260.0) + .show(ui, |ui| { + for (i, a) in b.attention.iter().enumerate() { + if i > 0 { + ui.add_space(4.0); + widgets::rule(ui); + ui.add_space(4.0); + } + let (glyph, color) = match a.severity { + Severity::Problem => (icon::WARNING_CIRCLE, p.negative), + Severity::Todo => (icon::CIRCLE_DASHED, p.warning), + Severity::Info => (icon::INFO, p.muted), + }; + ui.horizontal_top(|ui| { + ui.label(RichText::new(glyph).size(16.0).color(color)); + ui.vertical(|ui| { + let t = RichText::new(&a.title).font(theme::semibold(13.0)); + let r = widgets::link(ui, t); + if r.clicked() + && let Some(to) = &a.target + { + shell.go(to.clone()); + } + ui.label( + RichText::new(&a.detail) + .font(theme::regular(12.0)) + .color(p.muted), + ); + widgets::copy_prompt(ui, &a.prompt); + }); + }); + } + }); + }, + ); +} + +fn income_vs_spending(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let (start, end) = b.trailing_months(12); + let start = b.kpis.first_entry.map_or(start, |f| { + start.max(ledger_core::analysis::interval_start(f, Interval::Month)) + }); + let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default(); + widgets::titled_card( + ui, + "Income and spending by month", + |_| {}, + |ui| { + if months.is_empty() { + widgets::empty(ui, icon::CHART_BAR, "No months yet", ""); + return; + } + let groups: Vec = months + .iter() + .map(|m| BarGroup { + label: m.start.format("%b").to_string(), + values: vec![m.revenue, m.expenses, m.net()], + }) + .collect(); + let clicked = charts::bar_chart( + ui, + &groups, + &[ + ("Income", p.series[1]), + ("Spending", p.series[2]), + ("Net", p.series[0]), + ], + 220.0, + ); + if let Some(i) = clicked { + shell.state.reports.range = + crate::pages::reports::Range::Custom(months[i].start, months[i].end); + shell.go(Route::Reports(ReportKind::ProfitAndLoss)); + } + }, + ); +} + +fn where_it_went(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let (start, end) = b.fiscal_ytd(); + widgets::titled_card( + ui, + "Where the money went, this year", + |ui| { + ui.label( + RichText::new(format!( + "{} – {}", + fmt::date_short(start), + fmt::date_short(end) + )) + .font(theme::regular(12.0)) + .color(p.muted), + ); + }, + |ui| { + let Ok(pl) = profit_and_loss_for_period(&b.ledger, start, end, None) else { + return; + }; + let mut rows: Vec<(String, Decimal, String)> = pl + .expenses + .iter() + .map(|l| (l.name.clone(), l.amount, l.code.clone())) + .collect(); + rows.sort_by_key(|r| std::cmp::Reverse(r.1)); + rows.truncate(8); + if rows.is_empty() { + widgets::empty(ui, icon::CHART_BAR_HORIZONTAL, "No spending yet", ""); + return; + } + let bars: Vec<(String, Decimal)> = rows.iter().map(|r| (r.0.clone(), r.1)).collect(); + if let Some(i) = charts::hbars(ui, &bars, p.series[2]) + && let Some(a) = b.ledger.chart().find_by_code(&rows[i].2) + { + shell.go(Route::Account(a.id)); + } + }, + ); +} + +fn recent(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let since_update = shell.since_update; + let mut go = None; + let (full, _) = widgets::titled_card( + ui, + "Recent activity", + |ui| { + widgets::link( + ui, + RichText::new("Full journal") + .font(theme::regular(12.5)) + .color(p.accent), + ) + .clicked() + }, + |ui| { + let mut shown = 0; + for (i, line) in b.journal.iter().enumerate().rev() { + let Some(ev) = super::journal::describe(b, line) else { + continue; + }; + if shown == 12 { + break; + } + shown += 1; + let fresh = line.seq > b.fresh_from_seq && since_update < 8.0; + let row = egui::Frame::new() + .fill(if fresh { + p.new_highlight + } else { + egui::Color32::TRANSPARENT + }) + .corner_radius(egui::CornerRadius::same(6)) + .inner_margin(egui::Margin::symmetric(6, 3)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(ev.icon).size(15.0).color(p.muted)); + let r = widgets::link( + ui, + RichText::new(&ev.text).font(theme::regular(13.0)), + ); + if r.clicked() { + go = + Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq))); + } + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + RichText::new(fmt::timestamp(&line.ts)) + .font(theme::regular(11.5)) + .color(p.faint), + ); + }, + ); + }); + }); + let _ = (row, i); + } + if shown == 0 { + widgets::empty(ui, icon::CLOCK, "Nothing yet", ""); + } + }, + ); + if full { + go = Some(Route::Journal); + } + if let Some(r) = go { + shell.go(r); + } +} diff --git a/crates/ledger-gui/src/pages/profit.rs b/crates/ledger-gui/src/pages/profit.rs new file mode 100644 index 0000000..6e5b8c5 --- /dev/null +++ b/crates/ledger-gui/src/pages/profit.rs @@ -0,0 +1,377 @@ +//! Profit: are we making money, how is it trending, and what moved. +//! Spending by category this month against the recent average, income by +//! customer and by class. + +use super::{columns, page}; +use crate::app::Shell; +use crate::books::Books; +use crate::charts::{self, BarGroup}; +use crate::fmt; +use crate::nav::{ReportKind, Route}; +use crate::theme::{self, pal}; +use crate::widgets; +use chrono::{Days, Months}; +use egui::{CornerRadius, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::analysis::{Interval, interval_start, pnl_by_interval}; +use ledger_core::reports::profit_and_loss_for_period; +use rust_decimal::Decimal; +use std::collections::BTreeMap; + +fn pct(part: Decimal, whole: Decimal) -> Option { + (!whole.is_zero()).then(|| (part / whole * Decimal::from(100)).round_dp(1)) +} + +pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + page(ui, "profit", |ui| { + let (ys, ye) = b.fiscal_ytd(); + let ytd = profit_and_loss_for_period(&b.ledger, ys, ye, None).ok(); + let (rev, exp) = ytd + .as_ref() + .map(|p| (p.total_revenue, p.total_expenses)) + .unwrap_or_default(); + let net = rev - exp; + let margin = pct(net, rev); + let sentence = match margin { + Some(m) => format!( + "This year: {} of income, {} of spending, {} {} — a {}% margin.", + fmt::dollars(rev), + fmt::dollars(exp), + fmt::dollars(net.abs()), + if net >= Decimal::ZERO { + "profit" + } else { + "loss" + }, + m + ), + None if exp.is_zero() => "No income or spending recorded this year yet.".into(), + None => format!( + "This year: {} of spending and no income yet.", + fmt::dollars(exp) + ), + }; + widgets::page_header(ui, "Profit", &sentence); + + let first = b.kpis.first_entry.unwrap_or(b.today); + let (start, end) = b.trailing_months(12); + let start = start.max(interval_start(first, Interval::Month)); + let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default(); + widgets::titled_card( + ui, + "By month", + |_| {}, + |ui| { + if months.is_empty() { + widgets::empty(ui, icon::CHART_BAR, "No months yet", ""); + return; + } + let groups: Vec = months + .iter() + .map(|m| BarGroup { + label: m.start.format("%b").to_string(), + values: vec![m.revenue, m.expenses, m.net()], + }) + .collect(); + if let Some(i) = charts::bar_chart( + ui, + &groups, + &[ + ("Income", p.series[1]), + ("Spending", p.series[2]), + ("Net", p.series[0]), + ], + 230.0, + ) { + shell.state.reports.range = + crate::pages::reports::Range::Custom(months[i].start, months[i].end); + shell.go(Route::Reports(ReportKind::ProfitAndLoss)); + } + ui.add_space(10.0); + // The same numbers as a table, with the margin. + let w = ui.available_width(); + let cw = 130.0; + let header = |ui: &mut Ui, cells: [&str; 5]| { + let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover()); + ui.painter().text( + r.left_center() + vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + cells[0], + theme::semibold(12.0), + p.muted, + ); + for (i, c) in cells[1..].iter().enumerate() { + ui.painter().text( + egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y), + egui::Align2::RIGHT_CENTER, + *c, + theme::semibold(12.0), + p.muted, + ); + } + }; + header(ui, ["Month", "Income", "Spending", "Net", "Margin"]); + for m in months.iter().rev() { + let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); + } + ui.painter().text( + r.left_center() + vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + fmt::month(m.start), + theme::regular(13.0), + p.text, + ); + let vals = [ + fmt::money(m.revenue), + fmt::money(m.expenses), + fmt::accounting(m.net()), + pct(m.net(), m.revenue) + .map(|x| format!("{x}%")) + .unwrap_or_else(|| "—".into()), + ]; + for (i, v) in vals.iter().enumerate() { + let color = if i == 2 && m.net() < Decimal::ZERO { + p.negative + } else { + p.text + }; + ui.painter().text( + egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y), + egui::Align2::RIGHT_CENTER, + v, + theme::regular(13.0), + color, + ); + } + if resp.clicked() { + shell.state.reports.range = + crate::pages::reports::Range::Custom(m.start, m.end); + shell.go(Route::Reports(ReportKind::ProfitAndLoss)); + } + } + }, + ); + ui.add_space(16.0); + spending_changes(ui, b, shell); + columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i { + 0 => by_customer(ui, b), + _ => by_class(ui, b), + }); + }); +} + +/// Each expense account: this month so far, last month, the three-month +/// average, and how far this month is running from it. +fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let ms = b.month_start(); + let prev = + profit_and_loss_for_period(&b.ledger, ms - Months::new(1), ms - Days::new(1), None).ok(); + let three = + profit_and_loss_for_period(&b.ledger, ms - Months::new(3), ms - Days::new(1), None).ok(); + let now = profit_and_loss_for_period(&b.ledger, ms, b.today, None).ok(); + let mut rows: BTreeMap = BTreeMap::new(); + let mut add = |s: &Option, slot: usize| { + for l in s.iter().flat_map(|s| &s.expenses) { + let e = rows.entry(l.code.clone()).or_insert(( + l.name.clone(), + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + )); + match slot { + 0 => e.1 = l.amount, + 1 => e.2 = l.amount, + _ => e.3 = l.amount / Decimal::from(3), + } + } + }; + add(&now, 0); + add(&prev, 1); + add(&three, 2); + let mut rows: Vec<_> = rows.into_iter().collect(); + rows.sort_by_key(|r| std::cmp::Reverse(r.1.3.max(r.1.1))); + let mut go = None; + widgets::titled_card( + ui, + "Spending by category", + |ui| { + ui.label( + RichText::new("this month so far vs. the last three months") + .font(theme::regular(12.0)) + .color(p.muted), + ); + }, + |ui| { + if rows.is_empty() { + widgets::empty(ui, icon::RECEIPT, "No spending yet", ""); + return; + } + let w = ui.available_width(); + let cw = 130.0; + let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover()); + for (i, c) in ["This month", "Last month", "3-month avg"] + .iter() + .enumerate() + { + ui.painter().text( + egui::pos2(r.right() - cw * (2 - i) as f32 - 4.0, r.center().y), + egui::Align2::RIGHT_CENTER, + *c, + theme::semibold(12.0), + p.muted, + ); + } + ui.painter().text( + r.left_center() + vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + "Account", + theme::semibold(12.0), + p.muted, + ); + for (code, (name, now, last, avg)) in &rows { + let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); + } + ui.painter().text( + r.left_center() + vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + format!("{code} {name}"), + theme::regular(13.0), + p.text, + ); + // Flag a category already running well above its average. + let hot = *avg > Decimal::ZERO && *now > *avg * Decimal::new(125, 2); + let vals = [(*now, hot), (*last, false), (*avg, false)]; + for (i, (v, flag)) in vals.iter().enumerate() { + let x = r.right() - cw * (2 - i) as f32 - 4.0; + ui.painter().text( + egui::pos2(x, r.center().y), + egui::Align2::RIGHT_CENTER, + fmt::money(v.round_dp(2)), + theme::regular(13.0), + if *flag { p.warning } else { p.text }, + ); + if *flag { + ui.painter().text( + egui::pos2(x - 86.0, r.center().y), + egui::Align2::RIGHT_CENTER, + icon::ARROW_UP_RIGHT, + egui::FontId::proportional(13.0), + p.warning, + ); + } + } + if resp.clicked() { + go = b.ledger.chart().find_by_code(code).map(|a| a.id); + } + } + }, + ); + if let Some(id) = go { + shell.state.accounts.range = Some((ms - Months::new(3), b.today)); + shell.go(Route::Account(id)); + } + ui.add_space(16.0); +} + +fn by_customer(ui: &mut Ui, b: &Books) { + let p = pal(ui.ctx()); + let (ys, ye) = b.fiscal_ytd(); + let mut by: BTreeMap = BTreeMap::new(); + for id in 1..b.ledger.next_invoice_id() { + if let Some(inv) = b.ledger.get_invoice(id) + && inv.date >= ys + && inv.date <= ye + { + let who = b + .ledger + .get_customer(inv.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(); + *by.entry(who).or_default() += inv.total(); + } + } + let mut rows: Vec<(String, Decimal)> = by.into_iter().collect(); + rows.sort_by_key(|r| std::cmp::Reverse(r.1)); + widgets::titled_card( + ui, + "Invoiced by customer, this year", + |_| {}, + |ui| { + if rows.is_empty() { + widgets::empty( + ui, + icon::USERS, + "No invoices this year", + "Income recorded without invoices doesn't show a customer.", + ); + return; + } + let total: Decimal = rows.iter().map(|r| r.1).sum(); + if let Some((top, v)) = rows.first() { + ui.label( + RichText::new(format!( + "{top} is {}% of invoiced income.", + pct(*v, total).unwrap_or_default() + )) + .font(theme::regular(12.5)) + .color(p.muted), + ); + ui.add_space(4.0); + } + charts::hbars(ui, &rows, p.series[1]); + }, + ); +} + +fn by_class(ui: &mut Ui, b: &Books) { + let p = pal(ui.ctx()); + let (ys, ye) = b.fiscal_ytd(); + let classes = b.ledger.list_classes(); + widgets::titled_card( + ui, + "Net income by class, this year", + |_| {}, + |ui| { + if classes.is_empty() { + widgets::empty( + ui, + icon::TAG, + "No classes", + "Tag lines with classes (departments, products) to see profit by each.", + ); + return; + } + let mut rows: Vec<(String, Decimal)> = classes + .iter() + .filter_map(|c| { + let pl = profit_and_loss_for_period(&b.ledger, ys, ye, Some(c.id)).ok()?; + let net = pl.total_revenue - pl.total_expenses; + (!pl.total_revenue.is_zero() || !pl.total_expenses.is_zero()) + .then(|| (c.name.clone(), net)) + }) + .collect(); + rows.sort_by_key(|r| std::cmp::Reverse(r.1)); + if rows.is_empty() { + widgets::empty( + ui, + icon::TAG, + "No classified income or spending this year", + "", + ); + return; + } + charts::hbars(ui, &rows, p.series[0]); + ui.label( + RichText::new("Only lines tagged with a class count here.") + .font(theme::regular(12.0)) + .color(p.faint), + ); + }, + ); +} diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs new file mode 100644 index 0000000..252e7f0 --- /dev/null +++ b/crates/ledger-gui/src/pages/reports.rs @@ -0,0 +1,1526 @@ +//! Financial statements and the accountant's reports, over any range, +//! split into months, quarters, or years, compared with a prior period. +//! Every amount drills down to the account's transactions for exactly the +//! dates behind it. Statements come from `ledger-core`; this module lays +//! them out. + +use crate::app::Shell; +use crate::books::Books; +use crate::fmt; +use crate::nav::{ReportKind, Route}; +use crate::theme::{self, pal}; +use crate::widgets; +use chrono::{Datelike, Days, Months, NaiveDate}; +use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::analysis::{Interval, intervals}; +use ledger_core::reports::{ + CashFlowParams, balance_sheet_as_of, cash_flow, general_ledger, members_equity, + profit_and_loss_for_period, trial_balance_as_of, +}; +use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary, vendor_1099_report}; +use rust_decimal::Decimal; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Range { + ThisMonth, + LastMonth, + ThisQuarter, + #[default] + YearToDate, + LastYear, + Last12Months, + AllTime, + Custom(NaiveDate, NaiveDate), +} + +impl Range { + pub fn dates(self, b: &Books) -> (NaiveDate, NaiveDate) { + let t = b.today; + let ms = b.month_start(); + match self { + Range::ThisMonth => (ms, t), + Range::LastMonth => (ms - Months::new(1), ms - Days::new(1)), + Range::ThisQuarter => ( + ledger_core::analysis::interval_start(t, Interval::Quarter), + t, + ), + Range::YearToDate => b.fiscal_ytd(), + Range::LastYear => { + let (s, _) = b.fiscal_ytd(); + (s - Months::new(12), s - Days::new(1)) + } + Range::Last12Months => (ms - Months::new(11), t), + Range::AllTime => (b.kpis.first_entry.unwrap_or(t).min(t), t), + Range::Custom(s, e) => (s, e), + } + } + + fn label(self) -> &'static str { + match self { + Range::ThisMonth => "This month", + Range::LastMonth => "Last month", + Range::ThisQuarter => "This quarter", + Range::YearToDate => "Year to date", + Range::LastYear => "Last year", + Range::Last12Months => "Last 12 months", + Range::AllTime => "All time", + Range::Custom(..) => "Custom", + } + } + + const PRESETS: [Range; 7] = [ + Range::ThisMonth, + Range::LastMonth, + Range::ThisQuarter, + Range::YearToDate, + Range::LastYear, + Range::Last12Months, + Range::AllTime, + ]; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Columns { + #[default] + Total, + Month, + Quarter, + Year, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Compare { + #[default] + None, + PriorPeriod, + PriorYear, +} + +#[derive(Default)] +pub struct State { + pub range: Range, + pub columns: Columns, + pub compare: Compare, +} + +// ---------------------------------------------------------------------- +// The table model every statement renders through. + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Section, + Line, + Subtotal, + Grand, + Note, +} + +#[derive(Clone, Copy)] +struct Drill { + account: AccountId, + start: NaiveDate, + end: NaiveDate, +} + +#[derive(Clone)] +struct Cell { + value: Option, + drill: Option, +} + +impl Cell { + fn none() -> Self { + Cell { + value: None, + drill: None, + } + } + fn of(v: Decimal) -> Self { + Cell { + value: Some(v), + drill: None, + } + } +} + +struct Row { + kind: Kind, + label: String, + detail: String, + cells: Vec, +} + +struct Table { + columns: Vec, + rows: Vec, + /// Format cells as percentages from this column index on. + pct_from: Option, +} + +impl Table { + fn new(columns: Vec) -> Self { + Table { + columns, + rows: Vec::new(), + pct_from: None, + } + } + fn section(&mut self, label: &str) { + self.rows.push(Row { + kind: Kind::Section, + label: label.into(), + detail: String::new(), + cells: Vec::new(), + }); + } + fn row(&mut self, kind: Kind, label: impl Into, cells: Vec) { + self.rows.push(Row { + kind, + label: label.into(), + detail: String::new(), + cells, + }); + } + fn note(&mut self, text: impl Into) { + self.rows.push(Row { + kind: Kind::Note, + label: text.into(), + detail: String::new(), + cells: Vec::new(), + }); + } + + /// Add "Change" and "%" columns comparing column 0 to column 1. + fn add_change(&mut self) { + self.columns.push("Change".into()); + self.columns.push("%".into()); + self.pct_from = Some(self.columns.len() - 1); + for r in &mut self.rows { + if r.cells.len() < 2 { + continue; + } + let (a, b) = (r.cells[0].value, r.cells[1].value); + let diff = match (a, b) { + (Some(a), Some(b)) => Some(a - b), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(-b), + _ => None, + }; + let pct = match (diff, b) { + (Some(d), Some(b)) if !b.is_zero() => { + Some((d / b.abs() * Decimal::from(100)).round_dp(1)) + } + _ => None, + }; + r.cells.push(Cell { + value: diff, + drill: None, + }); + r.cells.push(Cell { + value: pct, + drill: None, + }); + } + } + + /// Add a "% of income" column: each line as a share of total income. + fn add_share_of_income(&mut self) { + let income = self + .rows + .iter() + .find(|r| r.kind == Kind::Subtotal && r.label == "Total income") + .and_then(|r| r.cells.first()) + .and_then(|c| c.value) + .unwrap_or_default(); + if income.is_zero() { + return; + } + self.columns.push("% of income".into()); + self.pct_from = Some(self.columns.len() - 1); + for r in &mut self.rows { + if let Some(v) = r.cells.first().and_then(|c| c.value) { + r.cells + .push(Cell::of((v / income * Decimal::from(100)).round_dp(1))); + } + } + } + + fn tsv(&self) -> String { + let mut out = String::from("\t"); + out.push_str(&self.columns.join("\t")); + out.push('\n'); + for r in &self.rows { + out.push_str(&r.label); + for c in &r.cells { + out.push('\t'); + if let Some(v) = c.value { + out.push_str(&v.round_dp(2).to_string()); + } + } + out.push('\n'); + } + out + } +} + +fn col_title(s: NaiveDate, e: NaiveDate, columns: Columns) -> String { + match columns { + Columns::Month => s.format("%b %Y").to_string(), + Columns::Quarter => format!("Q{} {}", (s.month() - 1) / 3 + 1, s.year()), + Columns::Year => s.year().to_string(), + Columns::Total => { + if s.year() == e.year() { + format!("{} – {}", fmt::date_short(s), fmt::date_short(e)) + } else { + format!("{} – {}", fmt::month(s), fmt::month(e)) + } + } + } +} + +/// The column ranges for a range and split (with the prior period second +/// when comparing a single column). +fn column_ranges(st: &State, b: &Books) -> Vec<(NaiveDate, NaiveDate, String)> { + let (s, e) = st.range.dates(b); + let split = match st.columns { + Columns::Total => None, + Columns::Month => Some(Interval::Month), + Columns::Quarter => Some(Interval::Quarter), + Columns::Year => Some(Interval::Year), + }; + match split { + None => { + let mut v = vec![(s, e, col_title(s, e, Columns::Total))]; + if let Some((ps, pe)) = prior(st, s, e) { + v.push((ps, pe, col_title(ps, pe, Columns::Total))); + } + v + } + Some(i) => intervals(s, e, i) + .into_iter() + .map(|(a, z)| (a, z, col_title(a, z, st.columns))) + .collect(), + } +} + +fn prior(st: &State, s: NaiveDate, e: NaiveDate) -> Option<(NaiveDate, NaiveDate)> { + match st.compare { + Compare::None => None, + Compare::PriorYear => Some((s - Months::new(12), e - Months::new(12))), + Compare::PriorPeriod => { + let days = (e - s).num_days() + 1; + let pe = s - Days::new(1); + Some((pe - Days::new(days as u64 - 1), pe)) + } + } +} + +type Cols = [(NaiveDate, NaiveDate, String)]; + +fn to_jiff(d: NaiveDate) -> jiff::civil::Date { + jiff::civil::Date::new(d.year() as i16, d.month() as i8, d.day() as i8).expect("valid date") +} + +fn from_jiff(d: jiff::civil::Date) -> NaiveDate { + NaiveDate::from_ymd_opt(i32::from(d.year()), d.month() as u32, d.day() as u32) + .expect("valid date") +} + +// ---------------------------------------------------------------------- +// Builders. + +fn pnl(b: &Books, cols: &Cols, total: bool) -> Table { + let mut cols = cols.to_vec(); + if total && cols.len() > 1 { + cols.push((cols[0].0, cols[cols.len() - 1].1, "Total".into())); + } + let stmts: Vec<_> = cols + .iter() + .map(|(s, e, _)| profit_and_loss_for_period(&b.ledger, *s, *e, None).ok()) + .collect(); + let mut t = Table::new(cols.iter().map(|c| c.2.clone()).collect()); + let section = |t: &mut Table, + title: &str, + pick: &dyn Fn( + &ledger_core::ProfitAndLossStatement, + ) -> &Vec, + total_label: &str| { + let mut accounts: BTreeMap = BTreeMap::new(); + for s in stmts.iter().flatten() { + for l in pick(s) { + accounts.insert(l.code.clone(), l.name.clone()); + } + } + t.section(title); + let mut totals = vec![Decimal::ZERO; cols.len()]; + for (code, name) in &accounts { + let id = b.ledger.chart().find_by_code(code).map(|a| a.id); + let cells = cols + .iter() + .zip(&stmts) + .enumerate() + .map(|(i, ((s, e, _), st))| { + let v = st + .as_ref() + .and_then(|st| pick(st).iter().find(|l| &l.code == code)) + .map(|l| l.amount) + .unwrap_or_default(); + totals[i] += v; + Cell { + value: Some(v), + drill: id.map(|account| Drill { + account, + start: *s, + end: *e, + }), + } + }) + .collect(); + t.row(Kind::Line, format!("{code} {name}"), cells); + } + t.row( + Kind::Subtotal, + total_label, + totals.iter().map(|v| Cell::of(*v)).collect(), + ); + totals + }; + let rev = section(&mut t, "Income", &|s| &s.revenue, "Total income"); + let exp = section(&mut t, "Expenses", &|s| &s.expenses, "Total expenses"); + t.row( + Kind::Grand, + "Net income", + rev.iter().zip(&exp).map(|(r, e)| Cell::of(r - e)).collect(), + ); + t +} + +fn balance_sheet(b: &Books, dates: &[(NaiveDate, String)]) -> Table { + let stmts: Vec<_> = dates + .iter() + .map(|(d, _)| balance_sheet_as_of(&b.ledger, *d).ok()) + .collect(); + let start = b.kpis.first_entry.unwrap_or(b.today); + let mut t = Table::new(dates.iter().map(|d| d.1.clone()).collect()); + let section = |t: &mut Table, + title: &str, + pick: &dyn Fn( + &ledger_core::BalanceSheetStatement, + ) -> &Vec| { + let mut accounts: BTreeMap = BTreeMap::new(); + for s in stmts.iter().flatten() { + for l in pick(s) { + accounts.insert(l.code.clone(), l.name.clone()); + } + } + t.section(title); + for (code, name) in &accounts { + let id = b.ledger.chart().find_by_code(code).map(|a| a.id); + let cells = dates + .iter() + .zip(&stmts) + .map(|((d, _), st)| Cell { + value: Some( + st.as_ref() + .and_then(|st| pick(st).iter().find(|l| &l.code == code)) + .map(|l| l.amount) + .unwrap_or_default(), + ), + drill: id.map(|account| Drill { + account, + start: start.min(*d), + end: *d, + }), + }) + .collect(); + t.row(Kind::Line, format!("{code} {name}"), cells); + } + }; + let total = |f: &dyn Fn(&ledger_core::BalanceSheetStatement) -> Decimal| -> Vec { + stmts + .iter() + .map(|s| Cell { + value: s.as_ref().map(f), + drill: None, + }) + .collect() + }; + section(&mut t, "Assets", &|s| &s.assets); + t.row(Kind::Subtotal, "Total assets", total(&|s| s.total_assets)); + section(&mut t, "Liabilities", &|s| &s.liabilities); + t.row( + Kind::Subtotal, + "Total liabilities", + total(&|s| s.total_liabilities), + ); + section(&mut t, "Equity", &|s| &s.equity); + t.row( + Kind::Line, + "Net income not yet closed", + total(&|s| s.current_earnings), + ); + t.row(Kind::Subtotal, "Total equity", total(&|s| s.total_equity)); + t.row( + Kind::Grand, + "Total liabilities and equity", + total(&|s| s.total_liabilities + s.total_equity), + ); + if stmts + .iter() + .flatten() + .all(|s| s.total_assets == s.total_liabilities + s.total_equity) + { + t.note(format!( + "{} Assets equal liabilities plus equity in every column.", + icon::CHECK + )); + } + t +} + +fn financing_accounts(b: &Books) -> Vec { + b.ledger + .chart() + .all() + .filter(|a| { + let n = a.name.to_lowercase(); + a.account_type == AccountType::Liability + && (n.contains("loan") + || n.contains("note payable") + || n.contains("line of credit")) + }) + .map(|a| a.id) + .collect() +} + +fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table { + let mut cols = cols.to_vec(); + if total && cols.len() > 1 { + cols.push((cols[0].0, cols[cols.len() - 1].1, "Total".into())); + } + let fin = financing_accounts(b); + let stmts: Vec<_> = cols + .iter() + .map(|(s, e, _)| { + cash_flow( + &b.ledger, + &CashFlowParams { + start: *s, + end: *e, + cash_accounts: b.cash_accounts.clone(), + financing_accounts: fin.clone(), + }, + ) + .ok() + }) + .collect(); + let mut t = Table::new(cols.iter().map(|c| c.2.clone()).collect()); + let val = |f: &dyn Fn(&ledger_core::CashFlowStatement) -> Decimal| -> Vec { + stmts + .iter() + .map(|s| Cell { + value: s.as_ref().map(f), + drill: None, + }) + .collect() + }; + let lines = |t: &mut Table, + pick: &dyn Fn( + &ledger_core::CashFlowStatement, + ) -> &Vec| { + let mut labels: Vec<(String, Option)> = Vec::new(); + for s in stmts.iter().flatten() { + for l in pick(s) { + if !labels.iter().any(|(x, _)| x == &l.label) { + labels.push((l.label.clone(), l.account.clone())); + } + } + } + for (label, account) in labels { + let id = account + .as_deref() + .and_then(|c| b.ledger.chart().find_by_code(c)) + .map(|a| a.id); + let cells = cols + .iter() + .zip(&stmts) + .map(|((s, e, _), st)| Cell { + value: Some( + st.as_ref() + .and_then(|st| pick(st).iter().find(|l| l.label == label)) + .map(|l| l.amount) + .unwrap_or_default(), + ), + drill: id.map(|account| Drill { + account, + start: *s, + end: *e, + }), + }) + .collect(); + t.row(Kind::Line, label, cells); + } + }; + t.section("Operating activities"); + t.row(Kind::Line, "Net income", val(&|s| s.net_income)); + t.row( + Kind::Line, + "Depreciation (non-cash)", + val(&|s| s.depreciation), + ); + lines(&mut t, &|s| &s.working_capital); + t.row( + Kind::Subtotal, + "Cash from operations", + val(&|s| s.cash_from_operations), + ); + t.section("Investing activities"); + lines(&mut t, &|s| &s.investing); + t.row( + Kind::Subtotal, + "Cash from investing", + val(&|s| s.cash_from_investing), + ); + t.section("Financing activities"); + lines(&mut t, &|s| &s.financing); + t.row( + Kind::Subtotal, + "Cash from financing", + val(&|s| s.cash_from_financing), + ); + t.row( + Kind::Grand, + "Net change in cash", + val(&|s| s.net_change_in_cash), + ); + t.row(Kind::Line, "Cash at beginning", val(&|s| s.cash_beginning)); + t.row(Kind::Line, "Cash at end", val(&|s| s.cash_ending)); + let names: Vec = b + .cash_accounts + .iter() + .map(|a| b.account_label(*a)) + .collect(); + t.note(format!("Cash accounts: {}.", names.join(", "))); + t +} + +fn trial_balance(b: &Books, as_of: NaiveDate) -> Table { + let tb = trial_balance_as_of(&b.ledger, as_of); + let start = b.kpis.first_entry.unwrap_or(as_of).min(as_of); + let mut t = Table::new(vec!["Debit".into(), "Credit".into()]); + for l in &tb.lines { + let id = b.ledger.chart().find_by_code(&l.code).map(|a| a.id); + let d = id.map(|account| Drill { + account, + start, + end: as_of, + }); + let cell = |v: Decimal| Cell { + value: (!v.is_zero()).then_some(v), + drill: d, + }; + t.row( + Kind::Line, + format!("{} {}", l.code, l.name), + vec![cell(l.debit), cell(l.credit)], + ); + } + t.row( + Kind::Grand, + "Total", + vec![Cell::of(tb.total_debits), Cell::of(tb.total_credits)], + ); + if tb.total_debits == tb.total_credits { + t.note(format!("{} Debits equal credits.", icon::CHECK)); + } + t +} + +fn owners_equity(b: &Books, s: NaiveDate, e: NaiveDate) -> Table { + let company = &b.settings.company; + let members: Vec = company + .members + .iter() + .filter_map(|m| { + Some(MemberCapital { + name: m.name.clone(), + capital_account: b.ledger.chart().find_by_code(&m.capital_account)?.id, + distribution_account: m + .distribution_account + .as_deref() + .and_then(|c| b.ledger.chart().find_by_code(c)) + .map(|a| a.id), + share: m.share, + }) + }) + .collect(); + if !members.is_empty() + && let Ok(me) = members_equity(&b.ledger, s, e, &members) + { + let mut cols: Vec = me.members.iter().map(|m| m.name.clone()).collect(); + cols.push("Total".into()); + let mut t = Table::new(cols); + let all: Vec<&ledger_core::MemberEquityColumn> = me + .members + .iter() + .chain(std::iter::once(&me.total)) + .collect(); + let row = |t: &mut Table, + kind, + label: &str, + f: &dyn Fn(&ledger_core::MemberEquityColumn) -> Decimal| { + t.row(kind, label, all.iter().map(|m| Cell::of(f(m))).collect()); + }; + row( + &mut t, + Kind::Line, + &format!("Balance, {}", fmt::date(s - Days::new(1))), + &|m| m.beginning, + ); + row(&mut t, Kind::Line, "Contributions", &|m| m.contributions); + row(&mut t, Kind::Line, "Share of net income", &|m| m.net_income); + row(&mut t, Kind::Line, "Distributions", &|m| -m.distributions); + row(&mut t, Kind::Line, "Other", &|m| m.other); + row( + &mut t, + Kind::Grand, + &format!("Balance, {}", fmt::date(e)), + &|m| m.ending, + ); + let shares: Vec = members + .iter() + .map(|m| format!("{} {}%", m.name, (m.share * Decimal::from(100)).normalize())) + .collect(); + t.note(format!("Ownership: {}.", shares.join(", "))); + return t; + } + // Single owner or a corporation: every equity account's movement. + let mut t = Table::new(vec!["Beginning".into(), "Change".into(), "Ending".into()]); + let eq: Vec = + ledger_core::analysis::accounts_of_type(&b.ledger, &[AccountType::Equity]); + let (mut tb, mut te) = (Decimal::ZERO, Decimal::ZERO); + for a in eq { + let begin = b.balance_on(a, s - Days::new(1)); + let end = b.balance_on(a, e); + tb += begin; + te += end; + let d = Some(Drill { + account: a, + start: s, + end: e, + }); + t.row( + Kind::Line, + b.account_label(a), + vec![ + Cell::of(begin), + Cell { + value: Some(end - begin), + drill: d, + }, + Cell::of(end), + ], + ); + } + let ni_begin = balance_sheet_as_of(&b.ledger, s - Days::new(1)) + .map(|bs| bs.current_earnings) + .unwrap_or_default(); + let ni_end = balance_sheet_as_of(&b.ledger, e) + .map(|bs| bs.current_earnings) + .unwrap_or_default(); + t.row( + Kind::Line, + "Net income not yet closed", + vec![ + Cell::of(ni_begin), + Cell::of(ni_end - ni_begin), + Cell::of(ni_end), + ], + ); + t.row( + Kind::Grand, + "Total equity", + vec![ + Cell::of(tb + ni_begin), + Cell::of(te + ni_end - tb - ni_begin), + Cell::of(te + ni_end), + ], + ); + t +} + +fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table { + let mut t = Table::new(vec!["Book".into(), "Tax".into()]); + let Ok(ts) = tax_summary(&b.ledger, s, e, b.settings.company.tax_form) else { + return t; + }; + let sec = |t: &mut Table, title: &str, lines: &[ledger_core::TaxSummaryLine]| { + if lines.is_empty() { + return; + } + t.section(title); + for l in lines { + t.rows.push(Row { + kind: Kind::Line, + label: l.label.clone(), + detail: l.accounts.join(", "), + cells: vec![Cell::of(l.book), Cell::of(l.tax)], + }); + } + }; + sec(&mut t, "Income", &ts.income); + sec(&mut t, "Cost of goods sold", &ts.cost_of_goods); + sec(&mut t, "Deductions", &ts.deductions); + t.row( + Kind::Grand, + "Net", + vec![Cell::of(ts.book_net()), Cell::of(ts.tax_net())], + ); + if !ts.uncategorized.is_empty() { + t.section("Not categorized"); + for u in &ts.uncategorized { + let id = u + .account + .split_whitespace() + .next() + .and_then(|c| b.ledger.chart().find_by_code(c)) + .map(|a| a.id); + t.row( + Kind::Line, + &u.account, + vec![ + Cell { + value: Some(u.book), + drill: id.map(|account| Drill { + account, + start: s, + end: e, + }), + }, + Cell::none(), + ], + ); + } + } + let form = ts.form.map(fmt::tax_form).unwrap_or("not set"); + t.note(format!("Return: {form}. Deductible percentages applied (meals 50%, penalties 0%). A worksheet for the CPA, not a filing.")); + t +} + +fn aging(rows: Vec, as_of: NaiveDate) -> Table { + let mut t = Table::new(vec![ + "Current".into(), + "1–30".into(), + "31–60".into(), + "61–90".into(), + "Over 90".into(), + "Total".into(), + ]); + let mut tot = [Decimal::ZERO; 6]; + for r in &rows { + let v = [ + r.current, + r.days_30, + r.days_60, + r.days_90, + r.days_90_plus, + r.total(), + ]; + for (i, x) in v.iter().enumerate() { + tot[i] += x; + } + t.row( + Kind::Line, + &r.name, + v.iter() + .map(|x| Cell { + value: (!x.is_zero()).then_some(*x), + drill: None, + }) + .collect(), + ); + } + t.row( + Kind::Grand, + "Total", + tot.iter().map(|x| Cell::of(*x)).collect(), + ); + t.note(format!("Days past due as of {}.", fmt::date(as_of))); + t +} + +// ---------------------------------------------------------------------- +// The page. + +pub fn show(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let rect = ui.available_rect_before_wrap(); + let nav_w = 210.0; + let nav_rect = egui::Rect::from_min_size(rect.min, vec2(nav_w, rect.height())); + ui.painter().line_segment( + [nav_rect.right_top(), nav_rect.right_bottom()], + Stroke::new(1.0, p.border), + ); + let mut nav = ui.new_child(egui::UiBuilder::new().max_rect(nav_rect.shrink2(vec2(12.0, 24.0)))); + nav.label( + RichText::new("REPORTS") + .font(theme::semibold(11.5)) + .color(p.faint), + ); + nav.add_space(4.0); + let groups: [(&str, &[ReportKind]); 3] = [ + ( + "Statements", + &[ + ReportKind::ProfitAndLoss, + ReportKind::BalanceSheet, + ReportKind::CashFlow, + ReportKind::MembersEquity, + ], + ), + ( + "Ledgers", + &[ReportKind::TrialBalance, ReportKind::GeneralLedger], + ), + ( + "Tax and aging", + &[ + ReportKind::TaxSummary, + ReportKind::Vendor1099, + ReportKind::ArAging, + ReportKind::ApAging, + ], + ), + ]; + for (g, kinds) in groups { + nav.add_space(10.0); + nav.label(RichText::new(g).font(theme::regular(12.0)).color(p.muted)); + nav.add_space(2.0); + for k in kinds { + let on = *k == kind; + let (r, resp) = nav.allocate_exact_size(vec2(nav_w - 24.0, 30.0), Sense::click()); + if on { + nav.painter() + .rect_filled(r, CornerRadius::same(7), p.surface); + nav.painter().rect_stroke( + r, + CornerRadius::same(7), + Stroke::new(1.0, p.border), + egui::StrokeKind::Inside, + ); + } else if resp.hovered() { + nav.painter().rect_filled(r, CornerRadius::same(7), p.hover); + } + nav.painter().text( + r.left_center() + vec2(10.0, 0.0), + egui::Align2::LEFT_CENTER, + k.title(), + if on { + theme::semibold(13.0) + } else { + theme::regular(13.0) + }, + if on { p.text } else { p.muted }, + ); + if resp.clicked() { + shell.go(Route::Reports(*k)); + } + } + } + let body = egui::Rect::from_min_max(egui::pos2(nav_rect.right() + 1.0, rect.top()), rect.max); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body)); + child.set_clip_rect(body); + super::page(&mut child, "report", |ui| report(ui, b, kind, shell)); + ui.allocate_rect(rect, Sense::hover()); +} + +fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let point_in_time = matches!( + kind, + ReportKind::TrialBalance | ReportKind::ArAging | ReportKind::ApAging + ) || (kind == ReportKind::BalanceSheet + && shell.state.reports.columns == Columns::Total); + let splittable = matches!( + kind, + ReportKind::ProfitAndLoss | ReportKind::BalanceSheet | ReportKind::CashFlow + ); + let comparable = matches!( + kind, + ReportKind::ProfitAndLoss + | ReportKind::BalanceSheet + | ReportKind::CashFlow + | ReportKind::TaxSummary + ); + let st = &mut shell.state.reports; + + // Controls. + ui.horizontal_wrapped(|ui| { + egui::ComboBox::from_id_salt("range") + .selected_text(RichText::new(format!( + "{} {}", + icon::CALENDAR_BLANK, + st.range.label() + ))) + .width(170.0) + .show_ui(ui, |ui| { + for r in Range::PRESETS { + ui.selectable_value(&mut st.range, r, r.label()); + } + let (s, e) = st.range.dates(b); + if ui + .selectable_label(matches!(st.range, Range::Custom(..)), "Custom…") + .clicked() + { + st.range = Range::Custom(s, e); + } + }); + if let Range::Custom(s, e) = st.range { + let (mut js, mut je) = (to_jiff(s), to_jiff(e)); + ui.add( + egui_extras::DatePickerButton::new(&mut js) + .id_salt("from") + .calendar_week(false), + ); + ui.label(RichText::new("to").color(p.muted)); + ui.add( + egui_extras::DatePickerButton::new(&mut je) + .id_salt("to") + .calendar_week(false), + ); + let (s, e) = (from_jiff(js), from_jiff(je)); + st.range = Range::Custom(s.min(e), e.max(s)); + } + if splittable { + ui.add_space(8.0); + widgets::segmented( + ui, + &mut st.columns, + &[ + (Columns::Total, "Total"), + (Columns::Month, "Months"), + (Columns::Quarter, "Quarters"), + (Columns::Year, "Years"), + ], + ); + } + if comparable && (st.columns == Columns::Total || !splittable) { + ui.add_space(8.0); + egui::ComboBox::from_id_salt("compare") + .selected_text(match st.compare { + Compare::None => "No comparison", + Compare::PriorPeriod => "vs prior period", + Compare::PriorYear => "vs prior year", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut st.compare, Compare::None, "No comparison"); + ui.selectable_value(&mut st.compare, Compare::PriorPeriod, "vs prior period"); + ui.selectable_value(&mut st.compare, Compare::PriorYear, "vs prior year"); + }); + } + }); + ui.add_space(18.0); + + let (s, e) = st.range.dates(b); + let state = State { + range: st.range, + columns: if splittable { + st.columns + } else { + Columns::Total + }, + compare: if comparable && (st.columns == Columns::Total || !splittable) { + st.compare + } else { + Compare::None + }, + }; + let cols = column_ranges(&state, b); + let compare = state.compare != Compare::None; + let subtitle = if point_in_time { + format!("As of {}", fmt::date(e)) + } else { + format!("{} – {}", fmt::date(s), fmt::date(e)) + }; + + ui.label(RichText::new(kind.title()).font(theme::bold(26.0))); + ui.label( + RichText::new(format!( + "{} · {} · {} basis", + b.company_name(), + subtitle, + b.settings.company.basis + )) + .font(theme::regular(14.0)) + .color(p.muted), + ); + ui.add_space(14.0); + + let table = match kind { + ReportKind::ProfitAndLoss => { + let mut t = pnl(b, &cols, state.columns != Columns::Total); + if compare { + t.add_change(); + } else if cols.len() == 1 { + t.add_share_of_income(); + } + Some(t) + } + ReportKind::BalanceSheet => { + let dates: Vec<(NaiveDate, String)> = if state.columns == Columns::Total { + cols.iter() + .map(|(_, e, _)| (*e, format!("As of {}", fmt::date_short(*e)))) + .collect() + } else { + cols.iter() + .map(|(_, e, title)| (*e, title.clone())) + .collect() + }; + let mut t = balance_sheet(b, &dates); + if compare { + t.add_change(); + } + Some(t) + } + ReportKind::CashFlow => { + let mut t = cash_flow_table(b, &cols, state.columns != Columns::Total); + if compare { + t.add_change(); + } + Some(t) + } + ReportKind::TrialBalance => Some(trial_balance(b, e)), + ReportKind::MembersEquity => Some(owners_equity(b, s, e)), + ReportKind::TaxSummary => { + let mut t = tax(b, s, e); + if compare && let Some((ps, pe)) = prior(&state, s, e) { + let prev = tax(b, ps, pe); + // Side by side: the tax column now and then. + let mut m = Table::new(vec![ + cols[0].2.clone(), + cols.get(1).map(|c| c.2.clone()).unwrap_or_default(), + ]); + for r in &t.rows { + let other = prev + .rows + .iter() + .find(|x| x.label == r.label && x.kind == r.kind) + .and_then(|x| x.cells.get(1)) + .cloned() + .unwrap_or(Cell::none()); + let cells = if r.cells.is_empty() { + Vec::new() + } else { + vec![r.cells.get(1).cloned().unwrap_or(Cell::none()), other] + }; + m.rows.push(Row { + kind: r.kind, + label: r.label.clone(), + detail: r.detail.clone(), + cells, + }); + } + m.add_change(); + t = m; + } + Some(t) + } + ReportKind::ArAging => Some(aging(b.ledger.ar_aging(e), e)), + ReportKind::ApAging => Some(aging(b.ledger.ap_aging(e), e)), + ReportKind::Vendor1099 => { + vendor_1099(ui, b, e.year(), shell); + None + } + ReportKind::GeneralLedger => { + gl(ui, b, s, e, shell); + None + } + }; + if let Some(t) = table { + render(ui, &t, shell); + } +} + +fn render(ui: &mut Ui, t: &Table, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let tsv = t.tsv(); + let mut go: Option = None; + widgets::card(ui, |ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if ui + .add( + egui::Button::new( + RichText::new(format!("{} Copy for a spreadsheet", icon::COPY)) + .font(theme::regular(12.0)) + .color(p.muted), + ) + .frame(false), + ) + .on_hover_text("Copies the table as tab-separated values") + .clicked() + { + ui.ctx().copy_text(tsv.clone()); + } + }); + let col_w: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 }; + let cols_w = col_w * t.columns.len() as f32; + egui::ScrollArea::horizontal() + .id_salt("report_table") + .show(ui, |ui| { + // Labels get what's left, but never so much that the eye loses + // the line between a label and its amount. + let label_w = (ui.available_width() - cols_w).clamp(300.0, 480.0); + let width = label_w + cols_w; + ui.set_min_width(width); + let (hr, _) = ui.allocate_exact_size(vec2(width, 30.0), Sense::hover()); + for (i, c) in t.columns.iter().enumerate() { + let x = hr.left() + label_w + col_w * (i as f32 + 1.0) - 4.0; + ui.painter().text( + egui::pos2(x, hr.center().y), + egui::Align2::RIGHT_CENTER, + c, + theme::semibold(12.5), + p.muted, + ); + } + ui.painter().line_segment( + [hr.left_bottom(), hr.right_bottom()], + Stroke::new(1.0, p.border), + ); + for (ri, r) in t.rows.iter().enumerate() { + let h = match r.kind { + Kind::Section => 36.0, + Kind::Grand => 38.0, + Kind::Note => 32.0, + _ => { + if r.detail.is_empty() { + 27.0 + } else { + 40.0 + } + } + }; + let (rr, resp) = ui.allocate_exact_size(vec2(width, h), Sense::hover()); + if matches!(r.kind, Kind::Line) && resp.hovered() { + ui.painter().rect_filled(rr, CornerRadius::same(4), p.hover); + } + let (font, color, indent) = match r.kind { + Kind::Section => (theme::semibold(13.5), p.text, 0.0), + Kind::Line => (theme::regular(13.5), p.text, 18.0), + Kind::Subtotal => (theme::semibold(13.5), p.text, 0.0), + Kind::Grand => (theme::bold(14.5), p.text, 0.0), + Kind::Note => (theme::regular(12.0), p.muted, 0.0), + }; + let y = match r.kind { + Kind::Section => rr.bottom() - 11.0, + _ if !r.detail.is_empty() => rr.center().y - 7.0, + _ => rr.center().y, + }; + let label_g = ui + .painter() + .layout_no_wrap(r.label.clone(), font.clone(), color); + ui.painter().galley( + egui::pos2(rr.left() + indent + 4.0, y - label_g.size().y / 2.0), + label_g, + color, + ); + if !r.detail.is_empty() { + ui.painter().text( + egui::pos2(rr.left() + indent + 4.0, rr.center().y + 9.0), + egui::Align2::LEFT_CENTER, + &r.detail, + theme::regular(11.5), + p.faint, + ); + } + for (i, c) in r.cells.iter().enumerate() { + let Some(v) = c.value else { + continue; + }; + let is_pct = t.pct_from.is_some_and(|f| i >= f); + let text = if is_pct { + format!("{}%", v.normalize()) + } else { + fmt::accounting(v) + }; + let x1 = rr.left() + label_w + col_w * (i as f32 + 1.0) - 4.0; + let cell = egui::Rect::from_min_max( + egui::pos2(x1 - col_w + 8.0, rr.top()), + egui::pos2(x1, rr.bottom()), + ); + let cresp = ui.interact( + cell, + ui.id().with(("cell", ri, i)), + if c.drill.is_some() { + Sense::click() + } else { + Sense::hover() + }, + ); + let hovered = c.drill.is_some() && cresp.hovered(); + let vcolor = if hovered { p.accent } else { color }; + let vfont = if matches!(r.kind, Kind::Line) { + theme::regular(13.5) + } else { + font.clone() + }; + let g = ui.painter().layout_no_wrap(text, vfont, vcolor); + let pos = egui::pos2(x1 - g.size().x, y - g.size().y / 2.0); + if hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + ui.painter().line_segment( + [ + egui::pos2(pos.x, pos.y + g.size().y), + egui::pos2(x1, pos.y + g.size().y), + ], + Stroke::new(1.0, p.accent), + ); + } + ui.painter().galley(pos, g, vcolor); + let rule_x0 = x1 - col_w + 28.0; + if matches!(r.kind, Kind::Subtotal) { + ui.painter().line_segment( + [ + egui::pos2(rule_x0, rr.top() + 1.0), + egui::pos2(x1, rr.top() + 1.0), + ], + Stroke::new(1.0, p.faint), + ); + } + if matches!(r.kind, Kind::Grand) { + let painter = ui.painter(); + painter.line_segment( + [ + egui::pos2(rule_x0, rr.top() + 2.0), + egui::pos2(x1, rr.top() + 2.0), + ], + Stroke::new(1.0, p.text), + ); + let yb = rr.bottom() - 5.0; + painter.line_segment( + [egui::pos2(rule_x0, yb), egui::pos2(x1, yb)], + Stroke::new(1.0, p.text), + ); + painter.line_segment( + [egui::pos2(rule_x0, yb + 2.5), egui::pos2(x1, yb + 2.5)], + Stroke::new(1.0, p.text), + ); + } + if let Some(d) = c.drill { + let cresp = cresp.on_hover_text(format!( + "Show the transactions behind this ({} – {})", + fmt::date(d.start), + fmt::date(d.end) + )); + if cresp.clicked() { + go = Some(d); + } + } + } + } + }); + }); + if let Some(d) = go { + shell.state.accounts.range = Some((d.start, d.end)); + shell.go(Route::Account(d.account)); + } +} + +fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let Ok(g) = general_ledger(&b.ledger, s, e) else { + return; + }; + let mut go: Option = None; + for a in g + .accounts + .iter() + .filter(|a| !a.postings.is_empty() || !a.beginning_balance.is_zero()) + { + widgets::card(ui, |ui| { + ui.horizontal(|ui| { + let r = widgets::link( + ui, + RichText::new(format!("{} {}", a.code, a.name)).font(theme::semibold(14.0)), + ); + if r.clicked() + && let Some(acct) = b.ledger.chart().find_by_code(&a.code) + { + go = Some(Route::Account(acct.id)); + } + ui.label( + RichText::new(format!("{:?}", a.account_type)) + .font(theme::regular(12.0)) + .color(p.muted), + ); + }); + ui.add_space(6.0); + let w = ui.available_width(); + let fixed = [86.0, 52.0, 0.0, 110.0, 110.0, 120.0]; + let flex = (w - fixed.iter().sum::()).max(120.0); + let row = |ui: &mut Ui, + vals: [String; 6], + strong: bool, + color: egui::Color32| + -> egui::Response { + let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); + if resp.hovered() && !strong { + ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); + } + let painter = ui.painter_at(r); + let mut x = r.left(); + for (i, v) in vals.iter().enumerate() { + let cw = if i == 2 { flex } else { fixed[i] }; + let font = if strong { + theme::semibold(12.5) + } else { + theme::regular(12.5) + }; + if i >= 3 { + painter.text( + egui::pos2(x + cw - 4.0, r.center().y), + egui::Align2::RIGHT_CENTER, + v, + font, + color, + ); + } else { + let max_chars = ((cw - 8.0) / 6.8) as usize; + let shown: String = if v.chars().count() > max_chars { + v.chars() + .take(max_chars.saturating_sub(1)) + .collect::() + + "…" + } else { + v.clone() + }; + painter.text( + egui::pos2(x + 4.0, r.center().y), + egui::Align2::LEFT_CENTER, + shown, + font, + color, + ); + } + x += cw; + } + resp + }; + row( + ui, + [ + "Date".into(), + "#".into(), + "Description".into(), + "Debit".into(), + "Credit".into(), + "Balance".into(), + ], + true, + p.muted, + ); + row( + ui, + [ + String::new(), + String::new(), + "Beginning balance".into(), + String::new(), + String::new(), + fmt::accounting(a.beginning_balance), + ], + false, + p.muted, + ); + for post in &a.postings { + let r = row( + ui, + [ + fmt::date_short(post.date), + post.entry_id.to_string(), + format!("{} · {}", post.description, post.split), + if post.debit.is_zero() { + String::new() + } else { + fmt::money(post.debit) + }, + if post.credit.is_zero() { + String::new() + } else { + fmt::money(post.credit) + }, + fmt::accounting(post.balance), + ], + false, + p.text, + ); + if r.clicked() { + go = Some(Route::Entry(post.entry_id)); + } + } + row( + ui, + [ + String::new(), + String::new(), + "Totals and ending balance".into(), + fmt::money(a.total_debits), + fmt::money(a.total_credits), + fmt::accounting(a.ending_balance), + ], + true, + p.text, + ); + }); + ui.add_space(12.0); + } + if let Some(r) = go { + if let Route::Account(_) = r { + shell.state.accounts.range = Some((s, e)); + } + shell.go(r); + } +} + +fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let r = vendor_1099_report(&b.ledger, year, None); + ui.label( + RichText::new(format!( + "Calendar {year}. Threshold {} ($600 through 2025; $2,000 from 2026, indexed after). Counts cash paid on entries tagged with the vendor; card payments are reported by the card processor (1099-K) and drop out.", + fmt::dollars(r.threshold) + )) + .color(p.muted), + ); + ui.add_space(10.0); + let mut t = Table::new(vec!["Paid".into()]); + for v in &r.vendors { + let form = v.form.map(|f| f.label()).unwrap_or("No 1099"); + let w9 = if v.tin_on_file { + "W-9 on file" + } else { + "no W-9" + }; + let status = if v.reportable { + if v.tin_on_file { + "File a 1099" + } else { + "File a 1099 · get a W-9 first" + } + } else { + "Not reportable" + }; + t.rows.push(Row { + kind: Kind::Line, + label: v.name.clone(), + detail: format!("{form} · {w9} · {status}"), + cells: vec![Cell::of(v.paid)], + }); + } + if r.vendors.is_empty() { + t.note("No vendors marked for a 1099 and none paid this year."); + } + render(ui, &t, shell); +} diff --git a/crates/ledger-gui/src/pages/transactions.rs b/crates/ledger-gui/src/pages/transactions.rs new file mode 100644 index 0000000..eaa595b --- /dev/null +++ b/crates/ledger-gui/src/pages/transactions.rs @@ -0,0 +1,279 @@ +//! Every entry, searchable and filterable, with the selected one open +//! beside the list: its lines, its receipt rendered, its history. + +use super::page; +use crate::app::Shell; +use crate::books::{Books, EntryInfo}; +use crate::fmt; +use crate::nav::Route; +use crate::theme::{self, pal}; +use crate::widgets; +use egui::{CornerRadius, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::{AccountId, EntryStatus}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Filter { + #[default] + All, + Posted, + Drafts, + NoReceipt, + WithReceipt, +} + +#[derive(Default)] +pub struct State { + pub query: String, + pub filter: Filter, + pub account: Option, +} + +pub fn matches(e: &EntryInfo, st: &State) -> bool { + let f = match st.filter { + Filter::All => true, + Filter::Posted => e.status == EntryStatus::Posted, + Filter::Drafts => e.status == EntryStatus::Draft, + Filter::NoReceipt => e.receipts.is_empty(), + Filter::WithReceipt => !e.receipts.is_empty(), + }; + let a = st.account.is_none_or(|a| e.accounts.contains(&a)); + let q = st.query.trim().to_lowercase(); + let t = q.is_empty() || q.split_whitespace().all(|w| e.haystack.contains(w)); + f && a && t +} + +pub fn show(ui: &mut Ui, b: &Books, selected: Option, shell: &mut Shell<'_>) { + match selected.and_then(|id| b.entry(id)) { + Some(e) => { + let w = (ui.available_width() * 0.5).max(420.0); + super::split( + ui, + "transactions", + w, + shell, + |ui, shell| list(ui, b, selected, shell), + |ui, shell| super::entry::detail(ui, b, e, shell), + ); + } + None => list(ui, b, None, shell), + } +} + +fn list(ui: &mut Ui, b: &Books, selected: Option, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + page(ui, "transactions", |ui| { + let total = b.entries.len(); + widgets::page_header( + ui, + "Transactions", + &format!( + "{} recorded, newest first. Click one to see its lines, its receipt, and its history.", + fmt::count(total, "entry", "entries") + ), + ); + let st = &mut shell.state.transactions; + let narrow = ui.available_width() < 720.0; + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut st.query) + .hint_text(format!( + "{} Search description, account, vendor, amount", + icon::MAGNIFYING_GLASS + )) + .desired_width(if narrow { ui.available_width() } else { 340.0 }), + ); + if !narrow { + filters(ui, b, st); + } + }); + if narrow { + ui.add_space(6.0); + ui.horizontal(|ui| filters(ui, b, st)); + } + ui.add_space(12.0); + let rows: Vec<&EntryInfo> = b.entries.iter().filter(|e| matches(e, st)).collect(); + if rows.is_empty() { + widgets::card(ui, |ui| { + widgets::empty( + ui, + icon::MAGNIFYING_GLASS, + "No matching entries", + "Try a different search or filter.", + ) + }); + return; + } + widgets::card(ui, |ui| { + let mut last_month: Option<(i32, u32)> = None; + for e in rows { + let m = ( + chrono::Datelike::year(&e.date), + chrono::Datelike::month(&e.date), + ); + if last_month != Some(m) { + if last_month.is_some() { + ui.add_space(10.0); + } + last_month = Some(m); + ui.label( + RichText::new(fmt::month(e.date)) + .font(theme::semibold(12.5)) + .color(p.muted), + ); + ui.add_space(2.0); + } + if row(ui, b, e, selected == Some(e.id), shell.since_update).clicked() { + shell.go(Route::Entry(e.id)); + } + } + }); + }); +} + +fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> egui::Response { + let p = pal(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click()); + let fresh = b.fresh.contains(&e.id) && since_update < 8.0; + if on { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.selected); + } else if fresh { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.new_highlight); + } else if resp.hovered() { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.hover); + } + let painter = ui.painter_at(rect); + let left = rect.left() + 10.0; + // Date block. + painter.text( + egui::pos2(left, rect.center().y - 8.0), + egui::Align2::LEFT_CENTER, + e.date.format("%b").to_string().to_uppercase(), + theme::semibold(10.5), + p.faint, + ); + painter.text( + egui::pos2(left, rect.center().y + 8.0), + egui::Align2::LEFT_CENTER, + e.date.format("%-d").to_string(), + theme::semibold(16.0), + p.text, + ); + // Description and accounts. + let text_left = left + 44.0; + let amount_w = 120.0; + let max_w = (rect.right() - amount_w - 70.0 - text_left).max(60.0); + let desc = painter.layout(e.description.clone(), theme::semibold(13.5), p.text, max_w); + let desc = if desc.rows.len() > 1 { + painter.layout_no_wrap( + truncate(&e.description, (max_w / 7.5) as usize), + theme::semibold(13.5), + p.text, + ) + } else { + desc + }; + painter.galley(egui::pos2(text_left, rect.center().y - 17.0), desc, p.text); + let accounts: Vec = e.accounts.iter().map(|a| b.account_name(*a)).collect(); + let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → ")); + if let Some(v) = &e.party { + sub.push_str(&format!(" · {v}")); + } + let sub = truncate(&sub, (max_w / 6.3) as usize); + painter.text( + egui::pos2(text_left, rect.center().y + 10.0), + egui::Align2::LEFT_CENTER, + sub, + theme::regular(12.0), + p.muted, + ); + // Amount and badges. + let right = rect.right() - 10.0; + // Cash in reads green with a plus, cash out with a minus; entries that + // don't move cash show their size, muted. + let (amount, color) = if e.cash_effect > rust_decimal::Decimal::ZERO { + (format!("+{}", fmt::money(e.cash_effect)), p.positive) + } else if e.cash_effect < rust_decimal::Decimal::ZERO { + (fmt::money(e.cash_effect), p.text) + } else { + (fmt::money(e.amount), p.muted) + }; + painter.text( + egui::pos2(right, rect.center().y - 6.0), + egui::Align2::RIGHT_CENTER, + amount, + theme::semibold(14.0), + color, + ); + let mut badges: Vec<(&str, egui::Color32)> = Vec::new(); + if e.status == EntryStatus::Draft { + badges.push(("DRAFT", p.warning)); + } + if e.reversed_by.is_some() { + badges.push(("REVERSED", p.muted)); + } + if e.reversal_of.is_some() { + badges.push(("REVERSAL", p.muted)); + } + let mut x = right; + for (text, color) in badges { + let g = painter.layout_no_wrap(text.to_string(), theme::semibold(10.0), color); + x -= g.size().x; + painter.galley(egui::pos2(x, rect.center().y + 6.0), g, color); + x -= 8.0; + } + let clip = if !e.receipts.is_empty() { + Some((icon::PAPERCLIP, p.muted)) + } else if e.missing_receipt { + Some((icon::WARNING_CIRCLE, p.negative)) + } else { + None + }; + if let Some((g, c)) = clip { + painter.text( + egui::pos2(rect.right() - amount_w - 24.0, rect.center().y), + egui::Align2::CENTER_CENTER, + g, + egui::FontId::proportional(16.0), + c, + ); + } + resp +} + +fn truncate(s: &str, n: usize) -> String { + if s.chars().count() <= n { + s.to_string() + } else { + let t: String = s.chars().take(n.saturating_sub(1)).collect(); + format!("{t}…") + } +} + +fn filters(ui: &mut Ui, b: &Books, st: &mut State) { + widgets::segmented( + ui, + &mut st.filter, + &[ + (Filter::All, "All"), + (Filter::Posted, "Posted"), + (Filter::Drafts, "Drafts"), + (Filter::WithReceipt, "With receipt"), + (Filter::NoReceipt, "No receipt"), + ], + ); + if let Some(a) = st.account { + ui.add_space(6.0); + let label = format!("{} {} {}", icon::FUNNEL, b.account_label(a), icon::X); + if ui + .button(label) + .on_hover_text("Clear the account filter") + .clicked() + { + st.account = None; + } + } +} diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs new file mode 100644 index 0000000..9a7def7 --- /dev/null +++ b/crates/ledger-gui/src/palette.rs @@ -0,0 +1,305 @@ +//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text, +//! amount, or #id), vendors, and documents, ranked by a simple fuzzy match. + +use crate::books::Books; +use crate::fmt; +use crate::nav::{ReportKind, Route, Section}; +use crate::theme::{self, pal}; +use egui::{CornerRadius, Key, Margin, RichText, Sense, Stroke, vec2}; +use egui_phosphor::regular as icon; + +#[derive(Default)] +pub struct Palette { + open: bool, + query: String, + selected: usize, + focus: bool, +} + +struct Item { + icon: &'static str, + title: String, + detail: String, + route: Route, + score: i32, +} + +impl Palette { + pub fn open(&mut self) { + self.open = true; + self.query.clear(); + self.selected = 0; + self.focus = true; + } + + /// Show the palette if open; returns where to go. + pub fn show(&mut self, ctx: &egui::Context, books: &Books) -> Option { + if !self.open { + return None; + } + let p = pal(ctx); + let items = search(books, &self.query); + let mut go = None; + let (up, down, enter, esc) = ctx.input(|i| { + ( + i.key_pressed(Key::ArrowUp), + i.key_pressed(Key::ArrowDown), + i.key_pressed(Key::Enter), + i.key_pressed(Key::Escape), + ) + }); + if down { + self.selected = (self.selected + 1).min(items.len().saturating_sub(1)); + } + if up { + self.selected = self.selected.saturating_sub(1); + } + if esc { + self.open = false; + return None; + } + if enter && let Some(it) = items.get(self.selected) { + self.open = false; + return Some(it.route.clone()); + } + let modal = egui::Modal::new(egui::Id::new("palette")) + .frame( + egui::Frame::new() + .fill(p.surface) + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(14)) + .inner_margin(Margin::same(10)), + ) + .show(ctx, |ui| { + ui.set_width(620.0); + let edit = egui::TextEdit::singleline(&mut self.query) + .hint_text("Search entries, accounts, vendors, documents, reports…") + .font(theme::regular(16.0)) + .frame(egui::Frame::NONE) + .desired_width(f32::INFINITY); + let r = ui.add(edit); + if self.focus { + r.request_focus(); + self.focus = false; + } + if r.changed() { + self.selected = 0; + } + ui.add_space(6.0); + crate::widgets::rule(ui); + ui.add_space(4.0); + egui::ScrollArea::vertical() + .max_height(420.0) + .show(ui, |ui| { + if items.is_empty() { + ui.label(RichText::new("Nothing matches.").color(p.muted)); + } + for (i, it) in items.iter().enumerate() { + let (rect, resp) = ui.allocate_exact_size( + vec2(ui.available_width(), 40.0), + Sense::click(), + ); + if i == self.selected || resp.hovered() { + ui.painter() + .rect_filled(rect, CornerRadius::same(8), p.hover); + } + if i == self.selected { + ui.scroll_to_rect(rect, None); + } + let painter = ui.painter_at(rect); + painter.text( + rect.left_center() + vec2(12.0, 0.0), + egui::Align2::LEFT_CENTER, + it.icon, + egui::FontId::proportional(16.0), + p.muted, + ); + painter.text( + rect.left_center() + vec2(38.0, -7.0), + egui::Align2::LEFT_CENTER, + &it.title, + theme::regular(14.0), + p.text, + ); + painter.text( + rect.left_center() + vec2(38.0, 9.0), + egui::Align2::LEFT_CENTER, + &it.detail, + theme::regular(11.5), + p.faint, + ); + if resp.clicked() { + go = Some(it.route.clone()); + } + } + }); + }); + if modal.should_close() { + self.open = false; + } + if go.is_some() { + self.open = false; + } + go + } +} + +/// Subsequence match: every query character in order, rewarding +/// contiguous runs and word starts. None if no match. +fn fuzzy(hay: &str, query: &str) -> Option { + if query.is_empty() { + return Some(0); + } + let hay: Vec = hay.to_lowercase().chars().collect(); + let mut score = 0; + let mut hi = 0; + let mut prev_match = false; + for qc in query.to_lowercase().chars() { + if qc == ' ' { + prev_match = false; + continue; + } + let mut found = false; + while hi < hay.len() { + let c = hay[hi]; + hi += 1; + if c == qc { + score += if prev_match { 6 } else { 1 }; + if hi == 1 || !hay[hi - 2].is_alphanumeric() { + score += 4; + } + prev_match = true; + found = true; + break; + } + prev_match = false; + } + if !found { + return None; + } + } + Some(score) +} + +fn search(b: &Books, query: &str) -> Vec { + let q = query.trim(); + let mut out: Vec = Vec::new(); + let mut add = |icon: &'static str, title: String, detail: String, route: Route, hay: &str| { + if let Some(score) = fuzzy(hay, q) { + out.push(Item { + icon, + title, + detail, + route, + score, + }); + } + }; + for s in Section::ALL { + add( + s.icon(), + s.title().into(), + "Page".into(), + s.home(), + s.title(), + ); + } + for r in ReportKind::ALL { + add( + icon::FILE_TEXT, + r.title().into(), + "Report".into(), + Route::Reports(r), + &format!("report {}", r.title()), + ); + } + if !q.is_empty() { + for a in b.ledger.chart().all() { + add( + icon::TREE_STRUCTURE, + format!("{} {}", a.code, a.name), + format!("{:?} account", a.account_type), + Route::Account(a.id), + &format!("{} {}", a.code, a.name), + ); + } + let id_query = q.strip_prefix('#').and_then(|s| s.parse::().ok()); + for e in &b.entries { + let exact = id_query == Some(e.id); + let hay = &e.haystack; + let score = if exact { + Some(1000) + } else { + fuzzy(hay, q).filter(|_| q.len() >= 2) + }; + if let Some(score) = score { + // Prefer substring hits for entries: fuzzy over long text is noisy. + let bonus = if hay.contains(&q.to_lowercase()) { + 50 + } else { + 0 + }; + if bonus == 0 && !exact && q.len() < 4 { + continue; + } + out.push(Item { + icon: icon::RECEIPT, + title: format!("#{} {}", e.id, e.description), + detail: format!("{} · {}", fmt::date(e.date), fmt::dollars(e.amount)), + route: Route::Entry(e.id), + score: score + bonus, + }); + } + } + for v in b.ledger.list_vendors() { + if let Some(score) = fuzzy(&v.name, q) { + out.push(Item { + icon: icon::STOREFRONT, + title: v.name.clone(), + detail: "Vendor".into(), + route: Route::Company, + score, + }); + } + } + for a in &b.attachments { + let hay = format!("{} {}", a.file_name, a.description.as_deref().unwrap_or("")); + if let Some(score) = fuzzy(&hay, q) { + out.push(Item { + icon: icon::FILE, + title: a.file_name.clone(), + detail: format!( + "{} · {}", + fmt::label(&a.category), + a.description.as_deref().unwrap_or("") + ), + route: Route::Document(a.id), + score, + }); + } + } + } + out.sort_by_key(|i| std::cmp::Reverse(i.score)); + out.truncate(40); + out +} + +/// A short description of a route for the breadcrumb. +pub fn describe(b: &Books, r: &Route) -> String { + match r { + Route::Entry(id) => b + .entry(*id) + .map(|e| format!("#{} {}", e.id, e.description)) + .unwrap_or_default(), + Route::Reports(k) => k.title().into(), + Route::Account(id) => b.account_label(*id), + Route::Document(id) => b + .attachments + .iter() + .find(|a| a.id == *id) + .map(|a| a.file_name.clone()) + .unwrap_or_default(), + Route::JournalEvent(seq) => format!("Event {seq}"), + _ => String::new(), + } +} diff --git a/crates/ledger-gui/src/receipts.rs b/crates/ledger-gui/src/receipts.rs new file mode 100644 index 0000000..5597fbd --- /dev/null +++ b/crates/ledger-gui/src/receipts.rs @@ -0,0 +1,177 @@ +//! Rendering receipts and documents to textures, off the UI thread. +//! +//! PDFs are rasterized with `hayro` (pure Rust), images decoded with +//! `image`. Results are cached by blob hash: the same bytes never render +//! twice, and a hash is the content, so the cache never goes stale. Bytes +//! are read through the verifying blob store: a corrupt blob shows as an +//! error, never as a picture. + +use egui::{ColorImage, Context, TextureHandle, TextureOptions}; +use ledger_store::BlobStore; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender, channel}; + +pub enum State { + Loading, + Ready(Vec), + Failed(String), +} + +struct Job { + sha: String, + mime: String, + root: PathBuf, +} + +struct Done { + sha: String, + result: Result, String>, +} + +pub struct Receipts { + cache: HashMap, + jobs: Sender, + done: Receiver, +} + +impl Receipts { + pub fn new(ctx: &Context) -> Self { + let (jobs, job_rx) = channel::(); + let (done_tx, done) = channel(); + let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx)); + let workers = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4)); + for i in 0..workers { + let ctx = ctx.clone(); + let job_rx = job_rx.clone(); + let done_tx = done_tx.clone(); + std::thread::Builder::new() + .name(format!("receipt-render-{i}")) + .spawn(move || { + loop { + let job = match job_rx.lock() { + Ok(rx) => rx.recv(), + Err(_) => return, + }; + let Ok(job) = job else { + return; + }; + let result = render(&job); + let _ = done_tx.send(Done { + sha: job.sha, + result, + }); + ctx.request_repaint(); + } + }) + .expect("spawn renderer"); + } + Self { + cache: HashMap::new(), + jobs, + done, + } + } + + /// The rendering of a blob, starting it if needed. + pub fn get(&mut self, sha: &str, mime: &str, root: PathBuf) -> &State { + if !self.cache.contains_key(sha) { + self.cache.insert(sha.to_string(), State::Loading); + let _ = self.jobs.send(Job { + sha: sha.to_string(), + mime: mime.to_string(), + root, + }); + } + &self.cache[sha] + } + + /// Turn finished renders into textures. Call once per frame. + pub fn pump(&mut self, ctx: &Context) { + while let Ok(done) = self.done.try_recv() { + self.accept(ctx, done); + } + } + + fn accept(&mut self, ctx: &Context, done: Done) { + let state = match done.result { + Ok(images) => State::Ready( + images + .into_iter() + .enumerate() + .map(|(i, img)| { + ctx.load_texture( + format!("blob-{}-{i}", done.sha), + img, + TextureOptions::LINEAR, + ) + }) + .collect(), + ), + Err(e) => State::Failed(e), + }; + self.cache.insert(done.sha, state); + } + + /// Wait until nothing is rendering (tests and screenshots). + pub fn settle(&mut self, ctx: &Context) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while self.cache.values().any(|s| matches!(s, State::Loading)) + && std::time::Instant::now() < deadline + { + if let Ok(done) = self + .done + .recv_timeout(std::time::Duration::from_millis(100)) + { + self.accept(ctx, done); + } + } + } +} + +fn render(job: &Job) -> Result, String> { + let bytes = BlobStore::open_read_only(&job.root) + .get(&job.sha) + .map_err(|e| e.to_string())?; + let is_pdf = job.mime == "application/pdf" || bytes.starts_with(b"%PDF"); + if is_pdf { + render_pdf(bytes) + } else if job.mime.starts_with("image/") { + let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?; + Ok(vec![to_color_image(img.to_rgba8())]) + } else { + Err(format!("{} can't be previewed", job.mime)) + } +} + +fn render_pdf(bytes: Vec) -> Result, String> { + use hayro::hayro_interpret::InterpreterSettings; + use hayro::hayro_syntax::Pdf; + use hayro::vello_cpu::color::palette::css::WHITE; + use hayro::{PixmapSettings, RenderCache, RenderSettings, render}; + let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?; + let cache = RenderCache::new(); + let interp = InterpreterSettings::default(); + let settings = RenderSettings::default(); + let px = PixmapSettings { + x_scale: 2.0, + y_scale: 2.0, + bg_color: WHITE, + }; + let mut out = Vec::new(); + for page in pdf.pages().iter().take(30) { + let pixmap = render(page, &cache, &interp, &settings, &px); + let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?; + let img = image::load_from_memory(&png).map_err(|e| e.to_string())?; + out.push(to_color_image(img.to_rgba8())); + } + if out.is_empty() { + return Err("the PDF has no pages".into()); + } + Ok(out) +} + +fn to_color_image(img: image::RgbaImage) -> ColorImage { + let size = [img.width() as usize, img.height() as usize]; + ColorImage::from_rgba_unmultiplied(size, img.as_raw()) +} diff --git a/crates/ledger-gui/src/theme.rs b/crates/ledger-gui/src/theme.rs new file mode 100644 index 0000000..8f07520 --- /dev/null +++ b/crates/ledger-gui/src/theme.rs @@ -0,0 +1,228 @@ +//! Look and feel: one palette per mode, Inter with tabular figures, and +//! the spacing the whole app shares. + +use egui::{ + Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin, Stroke, + TextStyle, Visuals, +}; +use std::sync::Arc; + +/// Colors for one mode. Every color in the app comes from here. +#[derive(Clone, Copy, Debug)] +pub struct Palette { + pub bg: Color32, + pub surface: Color32, + pub sidebar: Color32, + pub hover: Color32, + pub selected: Color32, + pub border: Color32, + pub text: Color32, + pub muted: Color32, + pub faint: Color32, + pub accent: Color32, + pub accent_soft: Color32, + pub positive: Color32, + pub negative: Color32, + pub warning: Color32, + pub warning_soft: Color32, + pub negative_soft: Color32, + pub positive_soft: Color32, + pub new_highlight: Color32, + /// Chart series, in order. + pub series: [Color32; 6], +} + +pub const LIGHT: Palette = Palette { + bg: Color32::from_rgb(0xF6, 0xF6, 0xF4), + surface: Color32::from_rgb(0xFF, 0xFF, 0xFF), + sidebar: Color32::from_rgb(0xEF, 0xEF, 0xEC), + hover: Color32::from_rgb(0xF2, 0xF2, 0xF0), + selected: Color32::from_rgb(0xE8, 0xE9, 0xFB), + border: Color32::from_rgb(0xE4, 0xE4, 0xE0), + text: Color32::from_rgb(0x1B, 0x1B, 0x1F), + muted: Color32::from_rgb(0x67, 0x67, 0x70), + faint: Color32::from_rgb(0x9C, 0x9C, 0xA4), + accent: Color32::from_rgb(0x4F, 0x46, 0xE5), + accent_soft: Color32::from_rgb(0xEE, 0xEE, 0xFD), + positive: Color32::from_rgb(0x15, 0x80, 0x3D), + negative: Color32::from_rgb(0xB9, 0x1C, 0x1C), + warning: Color32::from_rgb(0xB4, 0x53, 0x09), + warning_soft: Color32::from_rgb(0xFE, 0xF3, 0xE2), + negative_soft: Color32::from_rgb(0xFD, 0xEC, 0xEC), + positive_soft: Color32::from_rgb(0xE8, 0xF5, 0xEC), + new_highlight: Color32::from_rgb(0xFF, 0xF7, 0xD6), + series: [ + Color32::from_rgb(0x4F, 0x46, 0xE5), + Color32::from_rgb(0x0D, 0x94, 0x88), + Color32::from_rgb(0xD9, 0x77, 0x06), + Color32::from_rgb(0xDB, 0x27, 0x77), + Color32::from_rgb(0x25, 0x63, 0xEB), + Color32::from_rgb(0x64, 0x74, 0x8B), + ], +}; + +pub const DARK: Palette = Palette { + bg: Color32::from_rgb(0x11, 0x12, 0x14), + surface: Color32::from_rgb(0x19, 0x1A, 0x1D), + sidebar: Color32::from_rgb(0x15, 0x16, 0x18), + hover: Color32::from_rgb(0x22, 0x23, 0x27), + selected: Color32::from_rgb(0x26, 0x27, 0x45), + border: Color32::from_rgb(0x2A, 0x2B, 0x30), + text: Color32::from_rgb(0xEC, 0xEC, 0xEE), + muted: Color32::from_rgb(0xA3, 0xA3, 0xAC), + faint: Color32::from_rgb(0x6E, 0x6E, 0x78), + accent: Color32::from_rgb(0x8B, 0x93, 0xFA), + accent_soft: Color32::from_rgb(0x24, 0x25, 0x45), + positive: Color32::from_rgb(0x4A, 0xDE, 0x80), + negative: Color32::from_rgb(0xF8, 0x71, 0x71), + warning: Color32::from_rgb(0xFB, 0xBF, 0x24), + warning_soft: Color32::from_rgb(0x33, 0x29, 0x16), + negative_soft: Color32::from_rgb(0x3A, 0x1C, 0x1E), + positive_soft: Color32::from_rgb(0x16, 0x30, 0x22), + new_highlight: Color32::from_rgb(0x36, 0x31, 0x18), + series: [ + Color32::from_rgb(0x8B, 0x93, 0xFA), + Color32::from_rgb(0x2D, 0xD4, 0xBF), + Color32::from_rgb(0xFB, 0xBF, 0x24), + Color32::from_rgb(0xF4, 0x72, 0xB6), + Color32::from_rgb(0x60, 0xA5, 0xFA), + Color32::from_rgb(0x94, 0xA3, 0xB8), + ], +}; + +/// The palette for the current mode. +pub fn pal(ctx: &Context) -> &'static Palette { + if ctx.global_style().visuals.dark_mode { + &DARK + } else { + &LIGHT + } +} + +pub const SEMIBOLD: &str = "semibold"; +pub const BOLD: &str = "bold"; + +pub fn regular(size: f32) -> FontId { + FontId::new(size, FontFamily::Proportional) +} + +pub fn semibold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(SEMIBOLD.into())) +} + +pub fn bold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(BOLD.into())) +} + +/// Install fonts: Inter with tabular figures frozen in, so every number in +/// the app lines up digit for digit. Phosphor icons ride along as a +/// fallback in every family. +pub fn install_fonts(ctx: &Context) { + let mut fonts = FontDefinitions::default(); + let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { + fonts + .font_data + .insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); + }; + add( + &mut fonts, + "InterTab-Regular", + include_bytes!("../assets/fonts/InterTab-Regular.ttf"), + ); + add( + &mut fonts, + "InterTab-SemiBold", + include_bytes!("../assets/fonts/InterTab-SemiBold.ttf"), + ); + add( + &mut fonts, + "InterTab-Bold", + include_bytes!("../assets/fonts/InterTab-Bold.ttf"), + ); + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + let fallbacks: Vec = fonts + .families + .get(&FontFamily::Proportional) + .cloned() + .unwrap_or_default(); + // Text first, icons as a fallback. The bundled Inter copies have their + // private-use glyphs removed, so Phosphor's icons (which live there) + // are never shadowed. + let family = |primary: &str| { + let mut v = vec![primary.to_owned()]; + v.extend(fallbacks.iter().cloned()); + v + }; + fonts + .families + .insert(FontFamily::Proportional, family("InterTab-Regular")); + fonts.families.insert( + FontFamily::Name(SEMIBOLD.into()), + family("InterTab-SemiBold"), + ); + fonts + .families + .insert(FontFamily::Name(BOLD.into()), family("InterTab-Bold")); + ctx.set_fonts(fonts); +} + +/// Apply the app's style for light or dark mode. +pub fn apply(ctx: &Context, dark: bool) { + let p = if dark { &DARK } else { &LIGHT }; + let mut visuals = if dark { + Visuals::dark() + } else { + Visuals::light() + }; + visuals.panel_fill = p.bg; + visuals.window_fill = p.surface; + visuals.extreme_bg_color = p.surface; + visuals.faint_bg_color = p.hover; + visuals.override_text_color = Some(p.text); + visuals.hyperlink_color = p.accent; + visuals.selection.bg_fill = p.selected; + visuals.selection.stroke = Stroke::new(1.0, p.accent); + visuals.window_stroke = Stroke::new(1.0, p.border); + visuals.window_corner_radius = CornerRadius::same(12); + visuals.menu_corner_radius = CornerRadius::same(10); + for w in [ + &mut visuals.widgets.noninteractive, + &mut visuals.widgets.inactive, + &mut visuals.widgets.hovered, + &mut visuals.widgets.active, + &mut visuals.widgets.open, + ] { + w.corner_radius = CornerRadius::same(8); + } + visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, p.border); + visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, p.text); + visuals.widgets.inactive.bg_fill = p.surface; + visuals.widgets.inactive.weak_bg_fill = p.surface; + visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, p.border); + visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, p.text); + visuals.widgets.hovered.bg_fill = p.hover; + visuals.widgets.hovered.weak_bg_fill = p.hover; + visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, p.border); + visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, p.text); + visuals.widgets.active.bg_fill = p.selected; + visuals.widgets.active.weak_bg_fill = p.selected; + visuals.widgets.active.fg_stroke = Stroke::new(1.0, p.text); + ctx.set_visuals(visuals); + ctx.all_styles_mut(|style| { + style.text_styles = [ + (TextStyle::Heading, semibold(20.0)), + (TextStyle::Body, regular(14.0)), + (TextStyle::Button, regular(14.0)), + (TextStyle::Small, regular(12.0)), + (TextStyle::Monospace, FontId::monospace(12.5)), + ] + .into(); + style.spacing.item_spacing = egui::vec2(8.0, 6.0); + style.spacing.button_padding = egui::vec2(10.0, 5.0); + style.spacing.interact_size.y = 28.0; + style.spacing.window_margin = Margin::same(16); + style.spacing.scroll.bar_width = 8.0; + style.spacing.scroll.floating = true; + style.interaction.selectable_labels = false; + }); +} diff --git a/crates/ledger-gui/src/widgets.rs b/crates/ledger-gui/src/widgets.rs new file mode 100644 index 0000000..2f28a07 --- /dev/null +++ b/crates/ledger-gui/src/widgets.rs @@ -0,0 +1,291 @@ +//! Small building blocks shared by every screen. + +use crate::theme::{self, pal}; +use egui::{ + Align, Color32, CornerRadius, Frame, Layout, Margin, Response, RichText, Sense, Stroke, Ui, + vec2, +}; + +/// A white (or dark) rounded surface with a hairline border. +pub fn card(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + let p = pal(ui.ctx()); + Frame::new() + .fill(p.surface) + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(12)) + .inner_margin(Margin::same(16)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + add(ui) + }) + .inner +} + +/// A card with a title row and a right-aligned extra (a link, a pill). +/// Returns what `extra` and `add` return. +pub fn titled_card( + ui: &mut Ui, + title: &str, + extra: impl FnOnce(&mut Ui) -> E, + add: impl FnOnce(&mut Ui) -> R, +) -> (E, R) { + card(ui, |ui| { + let e = ui + .horizontal(|ui| { + ui.label(RichText::new(title).font(theme::semibold(14.0))); + ui.with_layout(Layout::right_to_left(Align::Center), extra) + .inner + }) + .inner; + ui.add_space(8.0); + (e, add(ui)) + }) +} + +/// Page title and the sentence under it. +pub fn page_header(ui: &mut Ui, title: &str, subtitle: &str) { + let p = pal(ui.ctx()); + ui.label(RichText::new(title).font(theme::bold(26.0))); + if !subtitle.is_empty() { + ui.add_space(2.0); + let w = ui.available_width().min(860.0); + ui.allocate_ui(vec2(w, 0.0), |ui| { + ui.set_max_width(w); + ui.label( + RichText::new(subtitle) + .font(theme::regular(15.0)) + .color(p.muted), + ); + }); + } + ui.add_space(16.0); +} + +/// A rounded tag. +pub fn pill(ui: &mut Ui, text: &str, fg: Color32, bg: Color32) -> Response { + Frame::new() + .fill(bg) + .corner_radius(CornerRadius::same(255)) + .inner_margin(Margin::symmetric(8, 2)) + .show(ui, |ui| { + ui.label(RichText::new(text).font(theme::semibold(11.5)).color(fg)) + }) + .response +} + +/// Text that looks like plain text until you hover it, then like a link. +pub fn link(ui: &mut Ui, text: RichText) -> Response { + let p = pal(ui.ctx()); + let r = ui.add(egui::Label::new(text.clone()).sense(Sense::click())); + if r.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + let rect = r.rect; + ui.painter().line_segment( + [ + rect.left_bottom() + vec2(0.0, 1.0), + rect.right_bottom() + vec2(0.0, 1.0), + ], + Stroke::new(1.0, p.accent), + ); + } + r +} + +/// An amount, right-aligned in a fixed-width cell, clickable when `drill`. +pub fn amount_cell( + ui: &mut Ui, + text: &str, + width: f32, + color: Color32, + strong: bool, + drill: bool, +) -> Response { + let font = if strong { + theme::semibold(14.0) + } else { + theme::regular(14.0) + }; + let (rect, resp) = ui.allocate_exact_size( + vec2(width, 22.0), + if drill { + Sense::click() + } else { + Sense::hover() + }, + ); + let p = pal(ui.ctx()); + let hovered = drill && resp.hovered(); + let galley = ui.painter().layout_no_wrap( + text.to_string(), + font, + if hovered { p.accent } else { color }, + ); + let pos = egui::pos2( + rect.right() - galley.size().x, + rect.center().y - galley.size().y / 2.0, + ); + if hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + ui.painter().line_segment( + [ + egui::pos2(pos.x, rect.center().y + galley.size().y / 2.0), + egui::pos2(rect.right(), rect.center().y + galley.size().y / 2.0), + ], + Stroke::new(1.0, p.accent), + ); + } + ui.painter().galley(pos, galley, color); + resp +} + +/// A big number with a label, a change line, and an optional sparkline. +pub struct Kpi<'a> { + pub label: &'a str, + pub value: String, + pub sub: Option<(String, Color32)>, + pub spark: Option<&'a [f64]>, + pub how: &'a str, +} + +pub fn kpi(ui: &mut Ui, k: Kpi<'_>) -> Response { + let p = pal(ui.ctx()); + let resp = Frame::new() + .fill(p.surface) + .stroke(Stroke::new(1.0, p.border)) + .corner_radius(CornerRadius::same(12)) + .inner_margin(Margin::same(14)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(104.0); + ui.label( + RichText::new(k.label) + .font(theme::regular(12.5)) + .color(p.muted), + ); + ui.add_space(4.0); + ui.label(RichText::new(&k.value).font(theme::semibold(24.0))); + if let Some((text, color)) = &k.sub { + ui.add_space(2.0); + ui.label(RichText::new(text).font(theme::regular(12.0)).color(*color)); + } + if let Some(values) = k.spark + && values.len() > 1 + { + ui.add_space(6.0); + let (rect, _) = + ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover()); + crate::charts::sparkline(ui.painter(), rect, values, p.accent); + } + }) + .response; + if !k.how.is_empty() { + resp.on_hover_text(k.how) + } else { + resp + } +} + +/// A friendly placeholder when there's nothing to show yet. +pub fn empty(ui: &mut Ui, icon: &str, title: &str, text: &str) { + let p = pal(ui.ctx()); + ui.vertical_centered(|ui| { + ui.add_space(18.0); + ui.label(RichText::new(icon).size(28.0).color(p.faint)); + ui.add_space(4.0); + ui.label( + RichText::new(title) + .font(theme::semibold(14.0)) + .color(p.muted), + ); + ui.label( + RichText::new(text) + .font(theme::regular(12.5)) + .color(p.faint), + ); + ui.add_space(18.0); + }); +} + +/// "Copy prompt": puts an agent prompt on the clipboard. +pub fn copy_prompt(ui: &mut Ui, prompt: &str) { + let p = pal(ui.ctx()); + let id = ui.id().with(("copied", prompt)); + let copied_at: Option = ui.ctx().data(|d| d.get_temp(id)); + let now = ui.input(|i| i.time); + let recently = copied_at.is_some_and(|t| now - t < 1.5); + let label = if recently { + format!("{} Copied", egui_phosphor::regular::CHECK) + } else { + format!("{} Ask the agent", egui_phosphor::regular::SPARKLE) + }; + let r = ui + .add( + egui::Button::new( + RichText::new(label) + .font(theme::regular(12.0)) + .color(p.accent), + ) + .fill(p.accent_soft) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(6)), + ) + .on_hover_text(format!( + "Copies this prompt for your agent session:\n\n{prompt}" + )); + if r.clicked() { + ui.ctx().copy_text(prompt.to_string()); + ui.ctx().data_mut(|d| d.insert_temp(id, now)); + } + if recently { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(300)); + } +} + +/// A horizontal rule in the border color. +pub fn rule(ui: &mut Ui) { + let p = pal(ui.ctx()); + let w = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); + ui.painter().rect_filled(rect, 0.0, p.border); +} + +/// Selectable segmented control. +pub fn segmented(ui: &mut Ui, value: &mut T, options: &[(T, &str)]) -> bool { + let p = pal(ui.ctx()); + let mut changed = false; + Frame::new() + .fill(p.hover) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::same(2)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(|ui| { + for (v, label) in options { + let on = *value == *v; + let b = egui::Button::new( + RichText::new(*label) + .font(if on { + theme::semibold(12.5) + } else { + theme::regular(12.5) + }) + .color(if on { p.text } else { p.muted }), + ) + .fill(if on { p.surface } else { Color32::TRANSPARENT }) + .stroke(if on { + Stroke::new(1.0, p.border) + } else { + Stroke::NONE + }) + .corner_radius(CornerRadius::same(6)) + .min_size(vec2(0.0, 24.0)); + if ui.add(b).clicked() && !on { + *value = *v; + changed = true; + } + } + }); + }); + changed +} diff --git a/crates/ledger-gui/tests/live.rs b/crates/ledger-gui/tests/live.rs new file mode 100644 index 0000000..12f01be --- /dev/null +++ b/crates/ledger-gui/tests/live.rs @@ -0,0 +1,68 @@ +//! Live updates: a change written to the books (by the MCP server, in +//! practice) reaches the GUI's view model on its own, without a restart, +//! and the changed entry is marked fresh. + +use chrono::NaiveDate; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_gui::data::{Loader, Source, Update}; +use ledger_store::Store; +use rust_decimal::Decimal; +use std::time::{Duration, Instant}; + +#[test] +fn a_change_in_another_process_shows_up_live() { + let dir = std::env::temp_dir().join(format!("ledger-gui-live-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let data = dir.join("books-data"); + let mut store = Store::create(&data, Currency::USD).unwrap(); + let cash = store + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let equity = store + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + drop(store); + + let ctx = egui::Context::default(); + let loader = Loader::spawn( + Source { + data_dir: data.clone(), + books_toml: None, + today: NaiveDate::from_ymd_opt(2026, 10, 12), + }, + ctx, + ); + let first = match loader.wait(Duration::from_secs(10)) { + Some(Update::Books(b)) => b, + _ => panic!("no initial load"), + }; + assert_eq!(first.kpis.cash, Decimal::ZERO); + + // The writer (the MCP server's store) posts an entry. + let started = Instant::now(); + let mut store = Store::open(&data).unwrap(); + let d = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); + let e = store.create_entry(d, "Owner contribution").unwrap(); + let m = Money::new(Decimal::from(2500), Currency::USD); + store.add_line(e, cash, Side::Debit, m).unwrap(); + store.add_line(e, equity, Side::Credit, m).unwrap(); + store.post_entry(e).unwrap(); + drop(store); + + // The GUI sees it, without being asked. + let deadline = Instant::now() + Duration::from_secs(10); + let mut seen = None; + while Instant::now() < deadline { + if let Some(Update::Books(b)) = loader.wait(Duration::from_millis(200)) + && b.kpis.cash == Decimal::from(2500) + { + seen = Some(b); + break; + } + } + let b = seen.expect("the posted entry never reached the GUI"); + assert!(b.fresh.contains(&e), "the new entry is highlighted as new"); + assert!(b.seq() > first.seq()); + eprintln!("live update arrived in {:?}", started.elapsed()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs new file mode 100644 index 0000000..d7ea3be --- /dev/null +++ b/crates/ledger-gui/tests/screens.rs @@ -0,0 +1,88 @@ +//! Screenshots of every screen, for eyes. Set `SHOT_BOOKS` to a books dir +//! (containing `books-data/` and `wanderbooks.toml`) and `SHOT_DIR` to +//! where the PNGs go; without them this test does nothing. + +use egui_kittest::Harness; +use ledger_gui::nav::{ReportKind, Route}; +use ledger_gui::{App, Source, data}; +use std::path::PathBuf; + +#[test] +fn screenshots() { + let Ok(out) = std::env::var("SHOT_DIR") else { + return; + }; + let fixed_today: Option = std::env::var("SHOT_TODAY") + .ok() + .and_then(|d| d.parse().ok()); + let books = match std::env::var("SHOT_BOOKS") { + Ok(b) => PathBuf::from(b), + Err(_) => { + let d = fixed_today + .unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap()); + let dir = + std::env::temp_dir().join(format!("ledger-gui-shots-demo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + ledger_gui::demo::build(&dir, d).unwrap(); + dir + } + }; + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + let source = Source { + data_dir: books.join("books-data"), + books_toml: Some(books.join("wanderbooks.toml")), + today: fixed_today.or_else(|| { + std::env::var("SHOT_BOOKS") + .is_err() + .then(|| chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap()) + }), + }; + let only = std::env::var("SHOT_ONLY").ok(); + let dark = std::env::var("SHOT_DARK").is_ok(); + let first_entry = data::load_once(&source) + .unwrap() + .entries + .last() + .map(|e| e.id) + .unwrap_or(1); + let routes: Vec<(&str, Route)> = vec![ + ("overview", Route::Overview), + ("transactions", Route::Transactions), + ( + "entry", + Route::Entry( + std::env::var("SHOT_ENTRY") + .ok() + .and_then(|e| e.parse().ok()) + .unwrap_or(first_entry), + ), + ), + ("cash", Route::Cash), + ("profit", Route::Profit), + ("pnl", Route::Reports(ReportKind::ProfitAndLoss)), + ("balance", Route::Reports(ReportKind::BalanceSheet)), + ("accounts", Route::Accounts), + ("documents", Route::Documents), + ("company", Route::Company), + ("journal", Route::Journal), + ]; + for (name, route) in routes { + if only + .as_deref() + .is_some_and(|o| !o.split(',').any(|x| x == name)) + { + continue; + } + let books = data::load_once(&source).unwrap(); + let mut harness = Harness::builder() + .with_size(egui::vec2(1440.0, 920.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, dark, route.clone())); + harness.run_steps(4); + let ctx = harness.ctx.clone(); + harness.state_mut().settle(&ctx); + harness.run_steps(4); + let img = harness.render().unwrap(); + img.save(out.join(format!("{name}.png"))).unwrap(); + } +} diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index 4bf9a33..352e2ab 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" ledger-core = { path = "../ledger-core" } ledger-store = { path = "../ledger-store" } ledger-import = { path = "../ledger-import" } +ledger-config = { path = "../ledger-config" } base64 = "0.22" mime_guess = "2" rust_decimal = "1" diff --git a/crates/ledger-mcp/src/main.rs b/crates/ledger-mcp/src/main.rs index 0f9033a..1e2e1bb 100644 --- a/crates/ledger-mcp/src/main.rs +++ b/crates/ledger-mcp/src/main.rs @@ -9,7 +9,7 @@ //! receipt threshold) come from the books dir's `wanderbooks.toml`, by //! default the one next to the data dir. -mod company; +use ledger_config as company; mod policy; mod server; mod types; diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 0c7aa51..147c653 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -1,10 +1,10 @@ //! The MCP server: narrow typed tools over the store, with the receipt //! policy enforced server-side. -use crate::company::Company; use crate::policy::ReceiptPolicy; use crate::types::*; use chrono::NaiveDate; +use ledger_config::Company; use ledger_core::{ AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry, Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value, diff --git a/crates/ledger-store/src/blob.rs b/crates/ledger-store/src/blob.rs index fae1460..41499d9 100644 --- a/crates/ledger-store/src/blob.rs +++ b/crates/ledger-store/src/blob.rs @@ -25,6 +25,14 @@ impl BlobStore { }) } + /// Open an existing blob store for reading, creating nothing. A + /// missing directory just means no blobs yet. + pub fn open_read_only(root: &Path) -> Self { + Self { + root: root.to_path_buf(), + } + } + fn path_for(&self, sha_hex: &str) -> PathBuf { self.root .join(&sha_hex[0..2]) diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 28bfc0d..f95e8df 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -22,11 +22,13 @@ pub mod blob; pub mod error; pub mod journal; pub mod schema; +pub mod snapshot; pub mod store; pub use blob::BlobStore; pub use error::StoreError; pub use journal::{GENESIS_PREV, JournalLine, JournalWriter}; +pub use snapshot::Snapshot; pub use store::{ Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE, PayrollLineInput, Store, diff --git a/crates/ledger-store/src/snapshot.rs b/crates/ledger-store/src/snapshot.rs new file mode 100644 index 0000000..7736eb1 --- /dev/null +++ b/crates/ledger-store/src/snapshot.rs @@ -0,0 +1,175 @@ +//! [`Snapshot`]: a read-only view of the books, for viewers. +//! +//! A snapshot never writes to the data directory. It reads the journal, +//! verifies the hash chain, replays it into its own [`Ledger`], and projects +//! it into a private in-memory SQLite database with exactly the code the +//! writer uses, so attachments and events read the same as through +//! [`crate::Store`]. Blobs are read (and verified) straight from the blob +//! store. +//! +//! [`Snapshot::refresh`] follows a live journal: it reads only the bytes +//! appended since the last read, verifies they chain onto what it has, and +//! applies them. A line the writer is still in the middle of appending +//! (no newline yet) is left for the next refresh, never treated as +//! corruption. If the file shrank or no longer chains, the snapshot reloads +//! from scratch. + +use crate::blob::BlobStore; +use crate::error::StoreError; +use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex}; +use crate::store::{ + Attachment, BLOBS_DIR, JOURNAL_FILE, Store, apply_line_to_sqlite, query_attachments, + read_attachment_bytes, replay_line_into_ledger, +}; +use ledger_core::{Currency, EntryId, Ledger}; +use rusqlite::Connection; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +/// A read-only, live-refreshable view of a data directory. +pub struct Snapshot { + data_dir: PathBuf, + conn: Connection, + blobs: BlobStore, + ledger: Ledger, + base_currency: Currency, + lines: Vec, + /// Bytes of the journal consumed (complete lines only). + offset: u64, + /// Hash of the last line read: what the next line must chain onto. + head: String, +} + +impl Snapshot { + /// Load the books in `data_dir`, read-only. + pub fn load(data_dir: &Path) -> Result { + let journal_path = data_dir.join(JOURNAL_FILE); + if !journal_path.exists() { + return Err(StoreError::NotFound(data_dir.display().to_string())); + } + let conn = Connection::open_in_memory()?; + Store::migrate(&conn)?; + let mut snap = Self { + data_dir: data_dir.to_path_buf(), + conn, + blobs: BlobStore::open_read_only(&data_dir.join(BLOBS_DIR)), + ledger: Ledger::new(Currency::USD), + base_currency: Currency::USD, + lines: Vec::new(), + offset: 0, + head: GENESIS_PREV.to_string(), + }; + snap.read_new()?; + Ok(snap) + } + + /// Apply whatever the writer appended since the last read. Returns the + /// number of new journal lines (0 when nothing changed). + pub fn refresh(&mut self) -> Result { + let len = std::fs::metadata(self.data_dir.join(JOURNAL_FILE))?.len(); + if len < self.offset { + // Rewritten underneath us (e.g. restored from a backup). + *self = Self::load(&self.data_dir)?; + return Ok(self.lines.len()); + } + match self.read_new() { + Ok(n) => Ok(n), + Err(StoreError::JournalCorrupt(_)) => { + // No longer chains onto what we have: start over, and let a + // real corruption surface from the full load. + *self = Self::load(&self.data_dir)?; + Ok(self.lines.len()) + } + Err(e) => Err(e), + } + } + + fn read_new(&mut self) -> Result { + let mut file = std::fs::File::open(self.data_dir.join(JOURNAL_FILE))?; + file.seek(SeekFrom::Start(self.offset))?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf)?; + // Complete lines only: a torn tail is a write still in progress. + let Some(last_newline) = buf.iter().rposition(|&b| b == b'\n') else { + return Ok(0); + }; + let complete = &buf[..=last_newline]; + let mut added = 0; + for raw in complete.split(|&b| b == b'\n').filter(|l| !l.is_empty()) { + let line: JournalLine = serde_json::from_slice(raw).map_err(|e| { + StoreError::JournalCorrupt(format!( + "seq {}: unparseable line: {e}", + self.lines.len() + 1 + )) + })?; + let want_seq = self.lines.len() as u64 + 1; + if line.seq != want_seq { + return Err(StoreError::JournalCorrupt(format!( + "seq gap: expected {want_seq}, found {}", + line.seq + ))); + } + if line.prev != self.head { + return Err(StoreError::JournalCorrupt(format!( + "hash chain broken at seq {}", + line.seq + ))); + } + if line.seq == 1 { + let code = line.payload["base_currency"].as_str().unwrap_or("USD"); + self.base_currency = Currency::new(code) + .map_err(|_| StoreError::JournalCorrupt(format!("bad base_currency {code}")))?; + self.ledger = Ledger::new(self.base_currency); + } + replay_line_into_ledger(&mut self.ledger, &line, self.base_currency)?; + apply_line_to_sqlite(&self.conn, &line)?; + self.head = sha256_hex(line.to_canonical().as_bytes()); + self.lines.push(line); + added += 1; + } + self.offset += complete.len() as u64; + Ok(added) + } + + /// The replayed ledger: the source for every report. + pub fn ledger(&self) -> &Ledger { + &self.ledger + } + + /// The books' base currency. + pub fn base_currency(&self) -> Currency { + self.base_currency + } + + /// The data directory. + pub fn data_dir(&self) -> &Path { + &self.data_dir + } + + /// Every journal line read so far, in order: the complete history, + /// with timestamps and hashes. + pub fn journal(&self) -> &[JournalLine] { + &self.lines + } + + /// Sequence number of the last journal line read. + pub fn journal_seq(&self) -> u64 { + self.lines.len() as u64 + } + + /// Hash of the last journal line: the head of the verified chain. + pub fn head_hash(&self) -> &str { + &self.head + } + + /// Attachments (receipts and documents, withdrawn ones included), + /// optionally only one entry's. + pub fn attachments(&self, entry_id: Option) -> Result, StoreError> { + query_attachments(&self.conn, entry_id) + } + + /// An attachment's bytes, verified against its hash. + pub fn attachment_bytes(&self, attachment_id: u64) -> Result, StoreError> { + read_attachment_bytes(&self.conn, &self.blobs, attachment_id) + } +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 8fd5de0..ccc5f9b 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -27,7 +27,7 @@ use std::path::{Path, PathBuf}; pub const JOURNAL_FILE: &str = "journal.jsonl"; /// File names inside the data directory. pub const DB_FILE: &str = "wanderbooks.db"; -const BLOBS_DIR: &str = "blobs"; +pub(crate) const BLOBS_DIR: &str = "blobs"; /// One row of an entry listing. #[derive(Clone, Debug)] @@ -283,7 +283,7 @@ impl Store { matches!(check, Ok(s) if s == "ok") } - fn migrate(conn: &Connection) -> Result<(), StoreError> { + pub(crate) fn migrate(conn: &Connection) -> Result<(), StoreError> { let mut v = Self::current_version(conn)?; if v > schema::SCHEMA_VERSION { return Err(StoreError::SchemaTooNew(v, schema::SCHEMA_VERSION)); @@ -2106,36 +2106,12 @@ impl Store { &self, entry_id: Option, ) -> Result, StoreError> { - let mut sql = String::from( - "SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \ - b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \ - FROM attachments a \ - JOIN blobs b ON b.sha256 = a.blob_sha", - ); - if entry_id.is_some() { - sql.push_str(" WHERE a.entry_id = ?"); - } - sql.push_str(" ORDER BY a.id"); - let mut stmt = self.conn.prepare(&sql)?; - let rows: Vec = match entry_id { - Some(eid) => stmt - .query_map(params![eid as i64], row_to_attachment)? - .collect::>()?, - None => stmt - .query_map([], row_to_attachment)? - .collect::>()?, - }; - Ok(rows) + query_attachments(&self.conn, entry_id) } /// Read attachment bytes, verifying the SHA-256. pub fn attachment_bytes(&self, attachment_id: u64) -> Result, StoreError> { - let sha: String = self.conn.query_row( - "SELECT blob_sha FROM attachments WHERE id = ?", - params![attachment_id as i64], - |r| r.get(0), - )?; - self.blobs.get(&sha) + read_attachment_bytes(&self.conn, &self.blobs, attachment_id) } // ------------------------------------------------------------------ @@ -2795,7 +2771,7 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> { /// Replay one journal line into a ledger, asserting recorded ids. Used by /// hydration, `verify`, and `rebuild`: the journal is the script, the engine /// is the actor. -fn replay_line_into_ledger( +pub(crate) fn replay_line_into_ledger( ledger: &mut Ledger, line: &JournalLine, base_currency: Currency, @@ -3376,7 +3352,10 @@ fn check_replayed_entry( Ok(()) } -fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> { +pub(crate) fn apply_line_to_sqlite( + conn: &Connection, + line: &JournalLine, +) -> Result<(), StoreError> { let p = &line.payload; match line.line_type.as_str() { "store.created" => {} @@ -3795,6 +3774,47 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto Ok(()) } +/// Attachments in the projection, optionally only one entry's, by id. +pub(crate) fn query_attachments( + conn: &Connection, + entry_id: Option, +) -> Result, StoreError> { + let mut sql = String::from( + "SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \ + b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \ + FROM attachments a \ + JOIN blobs b ON b.sha256 = a.blob_sha", + ); + if entry_id.is_some() { + sql.push_str(" WHERE a.entry_id = ?"); + } + sql.push_str(" ORDER BY a.id"); + let mut stmt = conn.prepare(&sql)?; + let rows: Vec = match entry_id { + Some(eid) => stmt + .query_map(params![eid as i64], row_to_attachment)? + .collect::>()?, + None => stmt + .query_map([], row_to_attachment)? + .collect::>()?, + }; + Ok(rows) +} + +/// An attachment's bytes, verified against its SHA-256. +pub(crate) fn read_attachment_bytes( + conn: &Connection, + blobs: &BlobStore, + attachment_id: u64, +) -> Result, StoreError> { + let sha: String = conn.query_row( + "SELECT blob_sha FROM attachments WHERE id = ?", + params![attachment_id as i64], + |r| r.get(0), + )?; + blobs.get(&sha) +} + fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result { Ok(Attachment { id: r.get::<_, i64>(0)? as u64, diff --git a/crates/ledger-store/tests/snapshot.rs b/crates/ledger-store/tests/snapshot.rs new file mode 100644 index 0000000..a09f24e --- /dev/null +++ b/crates/ledger-store/tests/snapshot.rs @@ -0,0 +1,145 @@ +//! Snapshot: a read-only view that follows a live writer. + +use chrono::NaiveDate; +use ledger_core::reports::snapshot; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_store::{JOURNAL_FILE, Snapshot, Store}; +use rust_decimal::Decimal; +use std::io::Write; +use std::path::PathBuf; + +fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wanderbooks-snap-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn usd(n: i64) -> Money { + Money::new(Decimal::from(n), Currency::USD) +} + +fn post(store: &mut Store, day: u32, dr: u64, cr: u64, amount: i64) -> u64 { + let d = NaiveDate::from_ymd_opt(2026, 10, day).unwrap(); + let e = store.create_entry(d, "x").unwrap(); + store.add_line(e, dr, Side::Debit, usd(amount)).unwrap(); + store.add_line(e, cr, Side::Credit, usd(amount)).unwrap(); + store.post_entry(e).unwrap(); + e +} + +#[test] +fn a_snapshot_follows_the_writer_and_never_writes() { + let dir = test_dir("follow"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let cash = store + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let equity = store + .add_account("3000", "Equity", AccountType::Equity, None, false) + .unwrap(); + let e = post(&mut store, 1, cash, equity, 1000); + store + .attach_receipt(Some(e), "deposit.png", "image/png", b"png-bytes", None) + .unwrap(); + + let listing = |dir: &std::path::Path| { + let mut v: Vec<_> = walk(dir); + v.sort(); + v + }; + let before = listing(&dir); + let mut snap = Snapshot::load(&dir).unwrap(); + assert_eq!(listing(&dir), before, "loading writes nothing"); + assert_eq!(snap.journal_seq(), store.journal_seq()); + assert_eq!( + snapshot(snap.ledger()).unwrap(), + snapshot(store.ledger()).unwrap() + ); + let atts = snap.attachments(Some(e)).unwrap(); + assert_eq!(atts.len(), 1); + assert_eq!(snap.attachment_bytes(atts[0].id).unwrap(), b"png-bytes"); + + // The writer keeps going; refresh picks up exactly the new lines. + assert_eq!(snap.refresh().unwrap(), 0); + let seq = store.journal_seq(); + post(&mut store, 2, cash, equity, 250); + let added = snap.refresh().unwrap(); + assert_eq!(added as u64, store.journal_seq() - seq); + assert_eq!( + snapshot(snap.ledger()).unwrap(), + snapshot(store.ledger()).unwrap() + ); +} + +#[test] +fn a_half_written_line_waits_for_the_next_refresh() { + let dir = test_dir("torn"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + store + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + drop(store); + let mut snap = Snapshot::load(&dir).unwrap(); + let seq = snap.journal_seq(); + // Simulate the writer mid-append: half a line, no newline yet. + let journal = dir.join(JOURNAL_FILE); + let full = std::fs::read(&journal).unwrap(); + let mut store = Store::open(&dir).unwrap(); + store + .add_account("3000", "Equity", AccountType::Equity, None, false) + .unwrap(); + drop(store); + let grown = std::fs::read(&journal).unwrap(); + let new_line = &grown[full.len()..]; + std::fs::write(&journal, &full).unwrap(); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&journal) + .unwrap(); + f.write_all(&new_line[..new_line.len() / 2]).unwrap(); + f.flush().unwrap(); + assert_eq!(snap.refresh().unwrap(), 0, "torn tail is not corruption"); + f.write_all(&new_line[new_line.len() / 2..]).unwrap(); + f.flush().unwrap(); + assert_eq!(snap.refresh().unwrap(), 1); + assert_eq!(snap.journal_seq(), seq + 1); + assert!(snap.ledger().chart().find_by_code("3000").is_some()); +} + +#[test] +fn a_rewritten_journal_reloads() { + let dir = test_dir("rewrite"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let cash = store + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let equity = store + .add_account("3000", "Equity", AccountType::Equity, None, false) + .unwrap(); + let journal = dir.join(JOURNAL_FILE); + let early = std::fs::read(&journal).unwrap(); + post(&mut store, 1, cash, equity, 10); + drop(store); + let mut snap = Snapshot::load(&dir).unwrap(); + let later = snap.journal_seq(); + // Restored from an earlier backup: shorter file. + std::fs::write(&journal, &early).unwrap(); + snap.refresh().unwrap(); + assert!(snap.journal_seq() < later); + assert!(snap.ledger().posted_entries().next().is_none()); +} + +fn walk(dir: &std::path::Path) -> Vec<(String, u64)> { + let mut out = Vec::new(); + for e in std::fs::read_dir(dir).unwrap() { + let e = e.unwrap(); + let m = e.metadata().unwrap(); + if m.is_dir() { + out.extend(walk(&e.path())); + } else { + out.push((e.path().display().to_string(), m.len())); + } + } + out +} diff --git a/docs/architecture.md b/docs/architecture.md index 2e900b9..9eb064e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,6 @@ The core is a pure domain library and stays that way. | `ledger-import` | QuickBooks CSV read-only import (bank transactions, chart of accounts) | `ledger-core` | | `ledger-export` | Format renderers: CSV, XLSX, IIF journal entries (QuickBooks Desktop) | `ledger-core` | | `ledger-oracle` | Diff harness: our engine vs GnuCash on fixture books | `ledger-core`, `ledger-import` | -| `books-app` | egui UI (native + WASM), command-driven | `ledger-core`, `ledger-store` | Dependency direction is always inward toward `ledger-core`. Nothing in the core may depend on a future crate. UI concerns (formatting, locale, widgets) @@ -71,3 +70,13 @@ types, and a type system that makes invalid states (mutable posted entries, float money) unrepresentable. The storytold pipeline this project is modeled on is Rust + egui for the same reasons; when the UI crate arrives it will be one codebase compiling to native and WASM. + +## The GUI (`ledger-gui`) + +Read-only by construction: it opens a `ledger_store::Snapshot` (journal +verified and replayed into its own `Ledger`, projected into a private +in-memory SQLite, blobs read through the verifying store) and never writes +to the data directory. A loader thread watches the directory and applies +appended journal lines; the UI renders an immutable view model rebuilt per +change. Statements and series come from `ledger-core` (`reports`, +`analysis`); the GUI only arranges them. See `plan/gui.md`. diff --git a/plan/STATUS.md b/plan/STATUS.md index 905e653..5d191dc 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -249,3 +249,21 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done Journal events vendor.1099_set and entry.vendor_set. Schema v7. 64 tools. Also: requirements and parity no longer list finished R-FUTURE work as future (only budgets remain, declined). +- 2026-10-12: ledger-gui, the read-only desktop view (egui 0.36, wgpu). + Overview (the answer in a sentence, KPI tiles with honest comparisons, + cash chart, needs-attention with copyable agent prompts, recent + activity), Cash (accounts, in/out, burn and runway, coming up), Profit + (by month with margin, spending vs. its recent average, by customer and + class), Transactions (search, filters, cash direction, detail with + lines, the rendered receipt via hayro, and journal history), Reports + (P&L, balance sheet, cash flow, owners' equity, trial balance, GL, tax + summary, 1099, agings; date presets, month/quarter/year columns, prior + period/year comparison, % of income, drill-down to the account register + for exactly those dates, copy as TSV), Accounts and registers, + Documents, Company, Journal (every event, hash, payload), Ctrl+K + palette, back/forward, light/dark. Live: notify-driven, ~30 ms from + write to screen. Engine additions: ledger-store Snapshot (read-only, + incremental, torn-tail tolerant), ledger-core analysis (intervals, + balance series and history, P&L by interval, flows), ledger-config + (extracted from ledger-mcp; full profile, cash_accounts). Fonts: Inter + with tnum frozen in and private-use glyphs removed (Phosphor icons). diff --git a/plan/gui.md b/plan/gui.md new file mode 100644 index 0000000..2f3956d --- /dev/null +++ b/plan/gui.md @@ -0,0 +1,111 @@ +# ledger-gui — design + +Phase 4 of the roadmap. A desktop app (egui/eframe; web later) for the +people who own the books and the accountant who checks them. Same +audience as QuickBooks, none of its visual language. + +## What it is for + +It answers the questions an owner actually has, then shows the evidence: + +- **How much money do we have, and how long does it last?** Cash today, + the trend, burn, runway, what's coming due. +- **Are we making money?** Revenue, spending, and profit by month; what + changed and why. +- **Where does the money go?** Spending by category and vendor, over time. +- **Who owes whom?** Payables, receivables, cards, money owed to members. +- **Is anything wrong or missing?** Entries without receipts, drafts never + posted, unreconciled accounts, uncategorized tax lines, 1099 vendors with + no W-9, past periods left open, a journal that fails verification. +- **Can I trust this number?** Every number drills down to the lines that + make it, each line to its entry, each entry to its rendered receipt and + its complete journal history (who did what, when, hash-chained). + +An accountant gets the same data at full depth: statements with interval +columns and comparisons, the general ledger, trial balance, the raw +journal with its verification status. + +## Principles + +1. **Answer first.** Every screen leads with the answer in words and one + big number, then the chart, then the table, then the raw data. +2. **Everything drills down.** Any amount is a link. Navigation is a + history stack (back/forward, like a browser) with a breadcrumb. +3. **All the data, nothing hidden.** Withdrawn attachments, reversals, + drafts, every journal event: visible, marked for what they are. +4. **Read-only by construction.** The GUI has no write path: it reads the + hash-chained journal and the blob store and builds its own in-memory + view (`ledger_store::Snapshot`). Changes happen through the agent (MCP). + Where the GUI finds something to fix, it offers a ready-to-paste prompt + for the agent ("Attach the receipt for entry 12 — Anthropic, $5.82"). +5. **Live.** It follows the journal: a change made by the agent appears + within a second, highlighted as new. +6. **Numbers are typography.** Tabular figures everywhere (Inter with + `tnum` frozen in), right-aligned, parentheses for negatives in + statements, a minus sign elsewhere, never a float. +7. **No accounting logic in the GUI.** Statements, balances, and series + come from `ledger-core` (new reports land there, where MCP can use + them too). The GUI arranges and draws. + +## Screens + +- **Overview** — KPI tiles with sparkline and change vs. the prior period + (cash, runway, revenue, spending, net income, owed by / owed to); + cash over time; income vs. spending by month; spending by category; + needs-attention list; recent activity from the journal. +- **Cash** — every cash and card account with balance and trend; inflows + and outflows by month; burn and runway; known upcoming outflows + (recurring entries, bills due, schedules). +- **Profit** — P&L by month as chart and table, spending breakdown with + month-over-month change, by class. +- **Transactions** — the register of every entry: search, filters + (account, vendor, class, date, status, receipt yes/no, amount), and a + detail pane with lines, the rendered receipt (PDF pages, images), + vendor, reversal links, and the entry's journal history. +- **Reports** — P&L, balance sheet, cash flow, trial balance, general + ledger, members' equity, tax summary, A/R and A/P aging, 1099 + worksheet. Date presets, interval columns (total / month / quarter / + year), comparison to the prior period or year. Every amount drills down. +- **Accounts** — the chart with balances and tax categories; an account + opens its register with a running balance and a balance chart. +- **Documents** — every receipt and company document, rendered, with its + category, entry link, and withdrawal status. +- **Company** — profile, members and capital, periods, fixed assets, + amortization schedules, recurring entries, vendors and 1099 status. +- **Journal** — the raw event log, filterable, each event expandable to + its JSON, with the hash-chain verification result. + +Global: command palette (Ctrl+K) to jump to any account, entry, vendor, +document, or report; back/forward (Alt+←/→); light and dark themes. + +## Architecture + +- `ledger-store::Snapshot`: read-only. Verifies the journal chain, + replays it into a `Ledger`, projects it into an in-memory SQLite with + the same code the writer uses, reads blobs. `refresh()` applies only + new journal lines (or reloads if the file was rewritten). +- `ledger-config` (moved out of `ledger-mcp`): `wanderbooks.toml`. +- `ledger-gui`: + - `data` — a loader thread polls the journal and sends new snapshots. + - `model` — view models built from a snapshot (pure, unit-tested). + - `ui/*` — one module per screen; `widgets` (money, tiles, sparkline, + tables, charts); `theme`; `receipts` (PDF via `hayro`, images via + `image`; textures cached by blob hash). + - Tests: model unit tests, `egui_kittest` screenshots of every screen + against a fixture book. +- Web build later: the model is plain data, so a server can ship it. + +## Status (2026-10-12) + +Built: every screen above, the palette, history, themes, live updates +(tested end to end: a store write reaches the view model in ~30 ms), +receipt rendering for PDFs and images, a deterministic demo company +(`ledger-gui --demo`; also the screenshot fixture), and a screenshot test +(`SHOT_DIR=… cargo test -p ledger-gui --test screens`, optionally +`SHOT_BOOKS`, `SHOT_ONLY`, `SHOT_DARK`). + +Next, in rough order: a budget-free cash forecast with a confidence band; +reconciliation view (cleared vs. uncleared per bank account); receipt +zoom and multi-page navigation; keyboard navigation in lists; +virtualized lists for very large books; the web build (ship the view +model from a server). diff --git a/plan/roadmap.md b/plan/roadmap.md index 7f1995e..f0e4845 100644 --- a/plan/roadmap.md +++ b/plan/roadmap.md @@ -31,7 +31,9 @@ The primary interface is MCP, not a cloned GUI (Stephen's call, 2026-10-10). - Done when: a fixture book produces byte-identical snapshots from both engines with zero manual steps. -## Phase 4 — Read-only GUI +## Phase 4 — Read-only GUI (first version done, 2026-10-12) + +`ledger-gui`: see `plan/gui.md` for the design and what's built. - Consumes the `ledger-store` event log: live updates — the answer to "watch its files to update live."