diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b239d78..7b69832 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -43,7 +43,10 @@ jobs: args: --workspace --all-features --all-targets --locked - name: Cargo fmt if: matrix.action == 'fmt' - run: cargo fmt --all -- --check + run: | + rustup toolchain install nightly + rustup component add rustfmt --toolchain nightly + cargo +nightly fmt --all -- --check - name: Install cargo-nextest if: matrix.action == 'nextest' uses: taiki-e/install-action@nextest diff --git a/.gitignore b/.gitignore index 2bd733b..e88a27a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,16 +2,13 @@ .DS_Store # Integrated development environment -.idea -.fleet .vscode # Package manager ## Cargo target -## mdBook -book -index.html +## NPM +node_modules # Test data -nohup.out +tmp diff --git a/Cargo.lock b/Cargo.lock index 9ef2422..73ff803 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,20 +19,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046" [[package]] -name = "accessibility" +name = "accessibility-ng" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ad4ccded014d35a7b1a262bb1ebbc076593189748f0ff6d2ba19f8ddfecef0" +checksum = "d7cb28d49c934e5f32a0b2227510e00999423596eff62f257962db130c3fa716" dependencies = [ - "accessibility-sys", + "accessibility-sys-ng", + "cocoa 0.24.1", "core-foundation", + "core-graphics-types", + "objc", + "thiserror", ] [[package]] -name = "accessibility-sys" +name = "accessibility-sys-ng" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf09354dda54177da27bcb8b9b83d8cab947db1dc1538a310a5e9da1c57fd4c2" +checksum = "02eadf4b9910301a47799cea1a8eefa659536fec71f5b8496b583b5e521db0b3" dependencies = [ "core-foundation-sys", ] @@ -112,6 +116,20 @@ dependencies = [ "winit", ] +[[package]] +name = "active-win-pos-rs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c1d770875c536934a8e7150061b0dbddb919298f0ff762b0f8fc12c8928877" +dependencies = [ + "appkit-nsworkspace-bindings", + "core-foundation", + "core-graphics 0.23.2", + "objc", + "windows 0.48.0", + "xcb", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -154,19 +172,19 @@ dependencies = [ name = "air" version = "0.1.0" dependencies = [ - "accessibility", - "accessibility-sys", "app_dirs2", + "arboard", "async-openai", - "clipboard", "color-eyre", - "core-foundation", "eframe", "egui_commonmark", + "enigo", "futures", + "get-selected-text", "global-hotkey", "objc2-app-kit", - "reqwest", + "objc2-foundation", + "reqwew", "serde", "thiserror", "tokio", @@ -236,18 +254,31 @@ dependencies = [ "xdg", ] +[[package]] +name = "appkit-nsworkspace-bindings" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062382938604cfa02c03689ab75af0e7eb79175ba0d0b2bcfad18f5190702dd7" +dependencies = [ + "bindgen", + "objc", +] + [[package]] name = "arboard" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fb4009533e8ff8f1450a5bcbc30f4242a1d34442221f72314bea1f5dc9c7f89" dependencies = [ - "clipboard-win 5.3.1", + "clipboard-win", + "core-graphics 0.23.2", + "image 0.25.1", "log", "objc2 0.5.2", "objc2-app-kit", "objc2-foundation", "parking_lot", + "windows-sys 0.48.0", "x11rb", ] @@ -589,6 +620,29 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.68.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726e4313eb6ec35d2730258ad4e15b547ee75d6afaa1361a922e78e59b7d8078" +dependencies = [ + "bitflags 2.5.0", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.66", + "which", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -672,6 +726,16 @@ dependencies = [ "objc2 0.4.1", ] +[[package]] +name = "block2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58aa60e59d8dbfcc36138f5f18be5f24394d33b38b24f7fd0b1caa33095f22f" +dependencies = [ + "block-sys 0.2.1", + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.5.1" @@ -807,6 +871,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -829,34 +902,39 @@ dependencies = [ ] [[package]] -name = "clipboard" -version = "0.5.0" +name = "clang-sys" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25a904646c0340239dcf7c51677b33928bf24fdf424b79a57909c0109075b2e7" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ - "clipboard-win 2.2.0", - "objc", - "objc-foundation", - "objc_id", - "x11-clipboard", + "glob", + "libc", + "libloading 0.8.3", ] [[package]] name = "clipboard-win" -version = "2.2.0" +version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a093d6fed558e5fe24c3dfc85a68bb68f1c824f440d3ba5aca189e2998786b" +checksum = "79f4473f5144e20d9aceaf2972478f06ddf687831eafeeb434fbaf0acc4144ad" dependencies = [ - "winapi", + "error-code", ] [[package]] -name = "clipboard-win" -version = "5.3.1" +name = "cocoa" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79f4473f5144e20d9aceaf2972478f06ddf687831eafeeb434fbaf0acc4144ad" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" dependencies = [ - "error-code", + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", ] [[package]] @@ -869,7 +947,7 @@ dependencies = [ "block", "cocoa-foundation", "core-foundation", - "core-graphics", + "core-graphics 0.23.2", "foreign-types 0.5.0", "libc", "objc", @@ -998,6 +1076,19 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.23.2" @@ -1106,6 +1197,12 @@ dependencies = [ "syn 2.0.66", ] +[[package]] +name = "debug_print" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f215f9b7224f49fb73256115331f677d868b34d18b65dbe4db392e6021eea90" + [[package]] name = "deranged" version = "0.3.11" @@ -1235,7 +1332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "020e2ccef6bbcec71dbc542f7eed64a5846fc3076727f5746da8fd307c91bab2" dependencies = [ "bytemuck", - "cocoa", + "cocoa 0.25.0", "directories-next", "document-features", "egui", @@ -1245,7 +1342,7 @@ dependencies = [ "glow", "glutin", "glutin-winit", - "image", + "image 0.24.9", "js-sys", "log", "objc", @@ -1347,7 +1444,7 @@ checksum = "1b78779f35ded1a853786c9ce0b43fe1053e10a21ea3b23ebea411805ce41593" dependencies = [ "egui", "enum-map", - "image", + "image 0.24.9", "log", "mime_guess2", "serde", @@ -1369,6 +1466,12 @@ dependencies = [ "winit", ] +[[package]] +name = "either" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" + [[package]] name = "emath" version = "0.27.2" @@ -1388,6 +1491,23 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enigo" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0087a01fc8591217447d28005379fb5a183683cc83f0a4707af28cc6603f70fb" +dependencies = [ + "core-graphics 0.23.2", + "foreign-types-shared 0.3.1", + "icrate 0.1.2", + "libc", + "log", + "objc2 0.5.2", + "windows 0.56.0", + "xkbcommon", + "xkeysym", +] + [[package]] name = "enum-map" version = "2.7.3" @@ -1784,6 +1904,27 @@ dependencies = [ "version_check", ] +[[package]] +name = "get-selected-text" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c549df57295e7308958f904f0444a35507e33bba9ec2d58fbae45db58eba72d" +dependencies = [ + "accessibility-ng", + "accessibility-sys-ng", + "active-win-pos-rs", + "arboard", + "cocoa 0.24.1", + "core-foundation", + "core-graphics 0.22.3", + "debug_print", + "enigo", + "lru", + "macos-accessibility-client", + "objc", + "parking_lot", +] + [[package]] name = "gethostname" version = "0.4.3" @@ -1822,6 +1963,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "global-hotkey" version = "0.5.4" @@ -1829,7 +1976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89cb13e8c52c87e28a46eae3e5e65b8f0cd465c4c9e67b13d56c70412e792bc3" dependencies = [ "bitflags 2.5.0", - "cocoa", + "cocoa 0.25.0", "crossbeam-channel", "keyboard-types", "objc", @@ -1865,7 +2012,7 @@ dependencies = [ "glutin_egl_sys", "glutin_glx_sys", "glutin_wgl_sys", - "icrate", + "icrate 0.0.4", "libloading 0.8.3", "objc2 0.4.1", "once_cell", @@ -2163,6 +2310,16 @@ dependencies = [ "objc2 0.4.1", ] +[[package]] +name = "icrate" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb69199826926eb864697bddd27f73d9fddcffc004f5733131e15b465e30642" +dependencies = [ + "block2 0.4.0", + "objc2 0.5.2", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -2192,6 +2349,19 @@ dependencies = [ "png", ] +[[package]] +name = "image" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11" +dependencies = [ + "bytemuck", + "byteorder", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indenter" version = "0.3.3" @@ -2271,6 +2441,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + [[package]] name = "js-sys" version = "0.3.69" @@ -2314,6 +2490,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.155" @@ -2395,6 +2577,25 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "lru" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "macos-accessibility-client" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf7710fbff50c24124331760978fb9086d6de6288dcdb38b25a97f8b1bdebbb" +dependencies = [ + "core-foundation", + "core-foundation-sys", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2410,6 +2611,15 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +[[package]] +name = "memmap2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" +dependencies = [ + "libc", +] + [[package]] name = "memmap2" version = "0.9.4" @@ -2676,17 +2886,6 @@ dependencies = [ "objc_exception", ] -[[package]] -name = "objc-foundation" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" -dependencies = [ - "block", - "objc", - "objc_id", -] - [[package]] name = "objc-sys" version = "0.2.0-beta.2" @@ -2837,15 +3036,6 @@ dependencies = [ "cc", ] -[[package]] -name = "objc_id" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" -dependencies = [ - "objc", -] - [[package]] name = "object" version = "0.32.2" @@ -2980,6 +3170,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3097,6 +3293,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" +dependencies = [ + "proc-macro2", + "syn 2.0.66", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3133,6 +3339,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.31.0" @@ -3275,7 +3490,6 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", @@ -3333,6 +3547,22 @@ dependencies = [ "thiserror", ] +[[package]] +name = "reqwew" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35dc7112189e48f5963b8cadd25b30d43c7265e21c7e853bef0992b6138a6611" +dependencies = [ + "bytes", + "once_cell", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", +] + [[package]] name = "ring" version = "0.17.8" @@ -3503,7 +3733,7 @@ checksum = "82b2eaf3a5b264a521b988b2e73042e742df700c4f962cde845d1541adb46550" dependencies = [ "ab_glyph", "log", - "memmap2", + "memmap2 0.9.4", "smithay-client-toolkit", "tiny-skia", ] @@ -3633,6 +3863,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -3684,7 +3920,7 @@ dependencies = [ "cursor-icon", "libc", "log", - "memmap2", + "memmap2 0.9.4", "rustix 0.38.34", "thiserror", "wayland-backend", @@ -3876,6 +4112,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiff" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d172b0f4d3fba17ba89811858b9d3d97f928aece846475bbda076ca46736211" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.36" @@ -4499,7 +4746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b3a62929287001986fb58c789dce9b67604a397c15c611ad9f747300b6c283" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.31.0", "quote", ] @@ -4552,6 +4799,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "weezl" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" + [[package]] name = "wgpu" version = "0.19.4" @@ -4654,6 +4907,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.34", +] + [[package]] name = "widestring" version = "1.1.0" @@ -4697,8 +4962,8 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.48.0", + "windows-interface 0.48.0", "windows-targets 0.48.5", ] @@ -4708,7 +4973,17 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ - "windows-core", + "windows-core 0.52.0", + "windows-targets 0.52.5", +] + +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", "windows-targets 0.52.5", ] @@ -4721,6 +4996,18 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result", + "windows-targets 0.52.5", +] + [[package]] name = "windows-implement" version = "0.48.0" @@ -4732,6 +5019,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "windows-interface" version = "0.48.0" @@ -4743,6 +5041,26 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.5", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -4962,13 +5280,13 @@ dependencies = [ "calloop", "cfg_aliases", "core-foundation", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", - "icrate", + "icrate 0.0.4", "js-sys", "libc", "log", - "memmap2", + "memmap2 0.9.4", "ndk", "ndk-sys", "objc2 0.4.1", @@ -5025,15 +5343,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "x11-clipboard" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89bd49c06c9eb5d98e6ba6536cf64ac9f7ee3a009b2f53996d405b3944f6bcea" -dependencies = [ - "xcb", -] - [[package]] name = "x11-dl" version = "2.21.0" @@ -5068,12 +5377,13 @@ checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" [[package]] name = "xcb" -version = "0.8.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e917a3f24142e9ff8be2414e36c649d47d6cc2ba81f16201cdef96e533e02de" +checksum = "02e75181b5a62b6eeaa72f303d3cef7dbb841e22885bf6d3e66fe23e88c55dc6" dependencies = [ + "bitflags 1.3.2", "libc", - "log", + "quick-xml 0.30.0", ] [[package]] @@ -5098,6 +5408,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "xkbcommon" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e" +dependencies = [ + "libc", + "memmap2 0.8.0", + "xkeysym", +] + [[package]] name = "xkbcommon-dl" version = "0.4.2" diff --git a/Cargo.toml b/Cargo.toml index 2a120ff..ed83e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,23 +30,27 @@ vergen = { version = "8.2", features = ["build", "cargo", "git", "gitcl"] } [dependencies] # crates.io app_dirs2 = { version = "2.5" } +arboard = { version = "3.4" } async-openai = { version = "0.23" } -clipboard = { version = "0.5" } color-eyre = { version = "0.6" } -core-foundation = { version = "0.9" } eframe = { version = "0.27", features = ["persistence"] } egui_commonmark = { version = "0.16" } +enigo = { version = "0.2" } futures = { version = "0.3" } +get-selected-text = { version = "0.1" } global-hotkey = { version = "0.5" } -reqwest = { version = "0.12", features = ["blocking", "json"] } +reqwew = { version = "0.2" } serde = { version = "1.0", features = ["derive"] } thiserror = { version = "1.0" } -tokio = { version = "1.37", features = ["rt-multi-thread"] } +tokio = { version = "1.38", features = ["rt-multi-thread"] } toml = { version = "0.8" } tracing = { version = "0.1" } tracing-subscriber = { version = "0.3" } [target.'cfg(target_os = "macos")'.dependencies] -accessibility = { version = "0.1" } -accessibility-sys = { version = "0.1" } -objc2-app-kit = { version = "0.2", features = ["NSRunningApplication", "NSWorkspace", "libc"] } +# accessibility = { version = "0.1" } +# accessibility-sys = { version = "0.1" } +# core-foundation = { version = "0.9" } +# objc2 = { version = "0.5" } +objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", "NSRunningApplication", "NSWindow"] } +objc2-foundation = { version = "0.2" } diff --git a/README.md b/README.md index 605c440..4c8c688 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,20 @@ [![GitHub last commit](https://img.shields.io/github/last-commit/hack-ink/air?color=red&style=plastic)](https://github.com/hack-ink/air) + +## Architecture +Built upon [egui](https://github.com/emilk/egui), a fast and cross-platform GUI toolkit written in pure Rust. + +### Components +These items either have their own `refresh` logic or do not require frequent refreshing. +They are not time-sensitive, and their `refresh` method will be called at specific intervals (e.g., every 15 seconds). + +### OS +Provides wrapped APIs to interact with the operating system. + +### Services +These items are time-sensitive and require frequent checking or updating. +They will be spawned as separate threads and run in the background. + +### UI +The user interface components. diff --git a/asset/NotoSerifSC-VariableFont_wght.ttf b/asset/NotoSerifSC-VariableFont_wght.ttf new file mode 100755 index 0000000..6168dac Binary files /dev/null and b/asset/NotoSerifSC-VariableFont_wght.ttf differ diff --git a/src/air.rs b/src/air.rs index 1754d9c..e27941d 100644 --- a/src/air.rs +++ b/src/air.rs @@ -1,74 +1,119 @@ // std use std::{ - sync::{Arc, Mutex}, - thread, + sync::{atomic::Ordering, Once}, time::Duration, }; // crates.io -use eframe::{ - egui::{CentralPanel, Context, ViewportBuilder}, - icon_data, App, CreationContext, Frame, NativeOptions, Storage, -}; +use eframe::{egui::*, Frame, *}; +use tokio::runtime::Runtime; // self -use crate::{component::util::Timer, data::*, os::*, prelude::*}; +use crate::{ + component::Components, + os::{AppKit, Os}, + prelude::Result, + service::Services, + ui::Uis, +}; #[derive(Debug)] struct AiR { - init: Arc>, - active_timer: Timer, - data: Data, + once: Once, + runtime: Runtime, + components: Components, + services: Services, + uis: Uis, } impl AiR { - fn register_services(ctx: Context) -> Arc> { - let init = Arc::new(Mutex::new(true)); - let init_ = init.clone(); - - // Give some time to all components to initialize themselves. - thread::spawn(move || { - loop { - if !ctx.input(|i| i.focused) { - thread::sleep(Duration::from_millis(10)); - } else { - break; - } - } + fn init(ctx: &Context) -> Self { + Self::set_fonts(ctx); - *init_.try_lock().unwrap() = false; - }); + let once = Once::new(); + let runtime = Runtime::new().expect("runtime must be created"); + let components = Components::init(); + let services = Services::init(ctx); + let uis = Uis::init(); - init + Self { once, runtime, components, services, uis } } - fn new(creation_ctx: &CreationContext) -> Self { - Self { - init: Self::register_services(creation_ctx.egui_ctx.clone()), - active_timer: Timer::default(), - data: Data::new(&creation_ctx.egui_ctx), + fn set_fonts(ctx: &Context) { + let mut fonts = FontDefinitions::default(); + + // Cascadia Code. + fonts.font_data.insert( + "Cascadia Code".into(), + FontData::from_static(include_bytes!("../asset/CascadiaCode.ttf")), + ); + fonts + .families + .entry(FontFamily::Proportional) + .or_default() + .insert(0, "Cascadia Code".into()); + fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "Cascadia Code".into()); + // NotoSerifSC. + fonts.font_data.insert( + "NotoSerifSC".into(), + FontData::from_static(include_bytes!("../asset/NotoSerifSC-VariableFont_wght.ttf")), + ); + fonts.families.entry(FontFamily::Proportional).or_default().insert(1, "NotoSerifSC".into()); + fonts.families.entry(FontFamily::Monospace).or_default().insert(1, "NotoSerifSC".into()); + + ctx.set_fonts(fonts); + } + + fn try_unhide(&mut self, ctx: &Context) { + let to_hidden = self.services.to_hidden.load(Ordering::SeqCst); + let focused = ctx.input(|i| i.focused); + + if to_hidden && !focused { + self.components.active_timer.refresh(); + self.services.to_hidden.store(true, Ordering::SeqCst); + + // TODO: https://github.com/emilk/egui/discussions/4635. + // ctx.send_viewport_cmd(ViewportCommand::Minimized(true)); + Os::hide(); + } else if !to_hidden && focused { + // TODO: find a better place to initialize this. + self.once.call_once(Os::set_move_to_active_space); + self.services.to_hidden.store(true, Ordering::SeqCst); } } } impl App for AiR { fn update(&mut self, ctx: &Context, _: &mut Frame) { - CentralPanel::default().show(ctx, |ui| self.data.draw(ui)); + let air_ctx = AiRContext { + egui_ctx: ctx, + runtime: &self.runtime, + components: &mut self.components, + services: &mut self.services, + }; - // TODO: these will be called multiple times. - if !self.init.try_lock().map(|o| *o).unwrap_or(true) && !ctx.input(|i| i.focused) { - self.active_timer.pause(); + self.uis.draw(air_ctx); + // TODO?: these will be called multiple times, move to focus service. + self.try_unhide(ctx); - Os::hide(); - } - if self.active_timer.refresh() > Duration::from_secs(30) { - self.active_timer.reset(); - // TODO: refactor `try_update`. - self.data.refresh(); + if self.components.active_timer.duration() > Duration::from_secs(15) { + self.components.active_timer.refresh(); + + if self.uis.chat.input.is_empty() { + self.components.quote.refresh(&self.runtime); + } } } fn save(&mut self, _: &mut dyn Storage) { - self.data.save(); + self.components.setting.save().expect("setting must be saved"); } } +#[derive(Debug)] +pub struct AiRContext<'a> { + pub egui_ctx: &'a Context, + pub runtime: &'a Runtime, + pub components: &'a mut Components, + pub services: &'a mut Services, +} + pub fn launch() -> Result<()> { eframe::run_native( "AiR", @@ -79,10 +124,12 @@ pub fn launch() -> Result<()> { .expect("icon must be valid"), ) .with_inner_size((720., 360.)) - .with_min_inner_size((720., 360.)), + .with_min_inner_size((720., 360.)) + .with_transparent(true), + follow_system_theme: true, ..Default::default() }, - Box::new(|c| Box::new(AiR::new(c))), + Box::new(|c| Box::new(AiR::init(&c.egui_ctx))), )?; Ok(()) diff --git a/src/component.rs b/src/component.rs index 73353a4..1bd661f 100644 --- a/src/component.rs +++ b/src/component.rs @@ -1,8 +1,39 @@ -mod fundamental; +// TODO?: refresh trait. pub mod function; -pub mod hotkey; + +pub mod keyboard; + +pub mod net; + pub mod openai; -pub mod quoter; +use openai::OpenAi; + +mod quote; +use quote::Quoter; + pub mod setting; +use setting::Setting; + +pub mod timer; +use timer::Timer; + pub mod util; + +#[derive(Debug)] +pub struct Components { + pub active_timer: Timer, + pub setting: Setting, + pub quote: Quoter, + pub openai: OpenAi, +} +impl Components { + pub fn init() -> Self { + let active_timer = Timer::default(); + let setting = Setting::load().expect("setting must be loaded"); + let quote = Quoter::default(); + let openai = OpenAi::new(setting.ai.clone()); + + Self { active_timer, setting, quote, openai } + } +} diff --git a/src/component/function.rs b/src/component/function.rs index 4b5eb55..ffc7b40 100644 --- a/src/component/function.rs +++ b/src/component/function.rs @@ -1,3 +1,5 @@ +// TODO:detect input language. + #[derive(Debug)] pub enum Function { Polish, @@ -7,11 +9,9 @@ impl Function { pub fn prompt(&self) -> &'static str { match self { Self::Polish => - "\ - As an English professor, assist me in refining this text. \ + "As an English professor, assist me in refining this text. \ Amend any grammatical errors and enhance the language to sound more like a native speaker.\ - Provide the text alone, without any additional commentary.\ - ", + Provide the refined text only, without any other things.", // Self::Translate => "\ // Translate the following text into English.\ // ", diff --git a/src/component/fundamental.rs b/src/component/fundamental.rs deleted file mode 100644 index 3883215..0000000 --- a/src/component/fundamental.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod http; diff --git a/src/component/fundamental/http.rs b/src/component/fundamental/http.rs deleted file mode 100644 index be4bda0..0000000 --- a/src/component/fundamental/http.rs +++ /dev/null @@ -1,20 +0,0 @@ -// std -use std::time::Duration; -// crates.io -use reqwest::{ - blocking::{Client, ClientBuilder, Response}, - Result, -}; - -#[derive(Clone, Debug)] -pub struct HttpClient(Client); -impl HttpClient { - pub fn get(&self, url: &str) -> Result { - self.0.get(url).send() - } -} -impl Default for HttpClient { - fn default() -> Self { - Self(ClientBuilder::new().timeout(Duration::from_secs(5)).build().unwrap()) - } -} diff --git a/src/component/hotkey.rs b/src/component/hotkey.rs deleted file mode 100644 index 87ca773..0000000 --- a/src/component/hotkey.rs +++ /dev/null @@ -1,62 +0,0 @@ -// std -use std::{ - sync::mpsc::{self, Receiver}, - thread, - time::Duration, -}; -// crates.io -use clipboard::{ClipboardContext, ClipboardProvider}; -use global_hotkey::{ - hotkey::{Code, HotKey, Modifiers}, - GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState, -}; -// self -use crate::{component::function::Function, os::*, prelude::*}; - -#[derive(Debug)] -pub struct Hotkey(Receiver); -impl Hotkey { - pub fn register() -> Result { - let (tx, rx) = mpsc::channel(); - let manager = GlobalHotKeyManager::new()?; - let receiver = GlobalHotKeyEvent::receiver(); - // Hotkeys. - let hk_polish = HotKey::new(Some(Modifiers::CONTROL), Code::KeyU); - let hk_polish_id = hk_polish.id(); - - manager.register_all(&[hk_polish])?; - - thread::spawn(move || { - // The manager need to be kept alive during the whole program life. - let _manager = manager; - let mut clipboard = ClipboardContext::new().unwrap(); - - loop { - if let Ok(e) = receiver.try_recv() { - // tracing::info!("{e:?}"); - - if let HotKeyState::Pressed = e.state { - if e.id == hk_polish_id { - if let Some(t) = Os::selected_text() { - let _ = clipboard.set_contents(t); - } - - tx.send(Function::Polish).unwrap(); - - Os::unhide(); - } - } - } - - // Listening period. - thread::sleep(Duration::from_millis(100)); - } - }); - - Ok(Self(rx)) - } - - pub fn try_recv(&self) -> Result { - Ok(self.0.try_recv()?) - } -} diff --git a/src/component/keyboard.rs b/src/component/keyboard.rs new file mode 100644 index 0000000..7089cca --- /dev/null +++ b/src/component/keyboard.rs @@ -0,0 +1,22 @@ +// crates.io +use enigo::{Enigo, Settings}; +// self +use crate::prelude::*; + +#[derive(Debug)] +pub struct Keyboard { + enigo: Enigo, +} +impl Keyboard { + pub fn init() -> Result { + Ok(Self { enigo: Enigo::new(&Settings::default()).map_err(EnigoError::NewCon)? }) + } + + // pub fn copy(&mut self) -> Result<()> { + // self.enigo.key(Key::Other(0x37), Direction::Press).map_err(EnigoError::Input)?; + // self.enigo.key(Key::Other(0x08), Direction::Click).map_err(EnigoError::Input)?; + // self.enigo.key(Key::Other(0x37), Direction::Release).map_err(EnigoError::Input)?; + // + // Ok(()) + // } +} diff --git a/src/component/net.rs b/src/component/net.rs new file mode 100644 index 0000000..14e0572 --- /dev/null +++ b/src/component/net.rs @@ -0,0 +1,6 @@ +pub use reqwew::{Http, Response}; + +// crates.io +use reqwew::{once_cell::sync::Lazy, Client}; + +pub static HTTP_CLIENT: Lazy = reqwew::lazy(Default::default); diff --git a/src/component/openai.rs b/src/component/openai.rs index ecaf247..55c44e0 100644 --- a/src/component/openai.rs +++ b/src/component/openai.rs @@ -1,8 +1,5 @@ // std -use std::{ - sync::{Arc, Mutex}, - thread, -}; +use std::sync::{Arc, Mutex}; // crates.io use async_openai::{ config::OpenAIConfig, @@ -14,137 +11,109 @@ use async_openai::{ }, Client, }; +use eframe::egui::WidgetText; use futures::StreamExt; -// self -use crate::component::setting::Ai; +use serde::{Deserialize, Serialize}; use tokio::runtime::Runtime; +// self +use crate::{component::setting::Ai, prelude::*}; +#[derive(Debug)] pub struct OpenAi { - // TODO: custom API endpoints. - pub client: Client, + pub client: Arc>, + // TODO: use Mutex. pub setting: Ai, + pub output: Arc>, } impl OpenAi { pub fn new(setting: Ai) -> Self { Self { - client: Client::with_config(OpenAIConfig::new().with_api_key(&setting.api_key)), + client: Arc::new(Client::with_config( + OpenAIConfig::new() + .with_api_base("https://aihubmix.com/v1") + .with_api_key(&setting.api_key), + )), setting, + output: Arc::new(Mutex::new(Default::default())), } } - pub fn chat( - &self, - prompt: &str, - content: &str, - output: Arc>, - token_count: Arc>, - ) { - let model = self.setting.model.clone(); + pub fn chat(&self, runtime: &Runtime, prompt: &str, content: &str) -> Result<()> { let msg = [ - ChatCompletionRequestSystemMessageArgs::default() - .content(prompt) - .build() - .unwrap() - .into(), - ChatCompletionRequestUserMessageArgs::default() - .content(content) - .build() - .unwrap() - .into(), + ChatCompletionRequestSystemMessageArgs::default().content(prompt).build()?.into(), + ChatCompletionRequestUserMessageArgs::default().content(content).build()?.into(), ]; let req = CreateChatCompletionRequestArgs::default() - .model(&model) + .model(self.setting.model.as_str()) .temperature(self.setting.temperature_rounded()) .max_tokens(4_096_u16) .messages(&msg) - .build() - .unwrap(); + .build()?; let c = self.client.clone(); + let o = self.output.clone(); - thread::spawn(move || { - // TODO: use ctx. - Runtime::new().unwrap().block_on(async { - let mut s = c.chat().create_stream(req).await.unwrap(); - - // token_count.lock().unwrap().0 = tiktoken_rs::num_tokens_from_messages( - // &model, - // &[(&msg[0]).into(), (&msg[1]).into()], - // ) - // .unwrap(); - - while let Some(r) = s.next().await { - match r { - Ok(resp) => { - resp.choices.iter().for_each(|c| { - if let Some(c) = &c.delta.content { - let o = { - let mut o = output.lock().unwrap(); + runtime.spawn(async move { + match c.chat().create_stream(req).await { + Ok(mut s) => { + o.lock().expect("output must be available").clear(); - o.push_str(c); - - o.to_owned() - }; - - // token_count.lock().unwrap().1 = - // tiktoken_rs::num_tokens_from_messages( - // &model, - // &[(&ChatCompletionRequestMessage::from( - // ChatCompletionRequestAssistantMessageArgs::default( - // ) - // .content(o) - // .build() - // .unwrap(), - // )) - // .into()], - // ) - // .unwrap(); - } - }); - }, - Err(e) => println!("error: {e}"), + while let Some(r) = s.next().await { + match r { + Ok(resp) => { + resp.choices.iter().for_each(|c| { + if let Some(c) = &c.delta.content { + o.lock().expect("output must be available").push_str(c); + } + }); + }, + Err(e) => println!("failed to receive chat response: {e}"), + } } - } - }); + }, + Err(e) => { + tracing::error!("failed to create chat stream: {e}"); + }, + } }); + + Ok(()) } } -// TODO: use enum. -#[derive(Debug)] -pub struct Model; +// https://platform.openai.com/docs/models +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Model { + Gpt4o, + Gpt35Turbo, +} impl Model { - const GPT_3_5_TURBO_0301: &'static str = "gpt-3.5-turbo-0301"; - const GPT_3_5_TURBO_0613: &'static str = "gpt-3.5-turbo-0613"; - const GPT_4: &'static str = "gpt-4"; - const GPT_4_0613: &'static str = "gpt-4-0613"; - const GPT_4_1106_PREVIEW: &'static str = "gpt-4-1106-preview"; - const GPT_4_32K: &'static str = "gpt-4-32k"; - const GPT_4_32K_0613: &'static str = "gpt-4-32k-0613"; - - pub fn default() -> &'static str { - Self::GPT_4_1106_PREVIEW - } - - pub fn all() -> [&'static str; 7] { - [ - Self::GPT_3_5_TURBO_0301, - Self::GPT_3_5_TURBO_0613, - Self::GPT_4, - Self::GPT_4_0613, - Self::GPT_4_1106_PREVIEW, - Self::GPT_4_32K, - Self::GPT_4_32K_0613, - ] + pub fn as_str(&self) -> &'static str { + match self { + Self::Gpt4o => "gpt-4o", + Self::Gpt35Turbo => "gpt-3.5-turbo", + } } // https://openai.com/pricing - pub fn price_of(model: &str) -> (f32, f32) { - match model { - Self::GPT_3_5_TURBO_0301 | Self::GPT_3_5_TURBO_0613 => (0.000001, 0.000002), - Self::GPT_4 | Self::GPT_4_0613 | Self::GPT_4_32K_0613 => (0.00003, 0.00006), - Self::GPT_4_1106_PREVIEW => (0.00001, 0.00003), - Self::GPT_4_32K => (0.00006, 0.00012), - _ => unreachable!(), + pub fn prices(&self) -> (f32, f32) { + match self { + Self::Gpt4o => (0.000005, 0.000015), + Self::Gpt35Turbo => (0.0000005, 0.0000015), } } + + pub fn all() -> [Self; 2] { + [Self::Gpt4o, Self::Gpt35Turbo] + } +} +impl Default for Model { + fn default() -> Self { + Self::Gpt4o + } +} +#[allow(clippy::from_over_into)] +impl Into for &Model { + fn into(self) -> WidgetText { + self.as_str().into() + } } diff --git a/src/component/quote.rs b/src/component/quote.rs new file mode 100644 index 0000000..9be4a4e --- /dev/null +++ b/src/component/quote.rs @@ -0,0 +1,58 @@ +// std +use std::{ + borrow::Cow, + sync::{Arc, RwLock}, +}; +// serde +use serde::Deserialize; +use tokio::runtime::Runtime; +// self +use super::net::{Http, Response, HTTP_CLIENT}; + +#[derive(Debug)] +pub struct Quoter { + pub quote: Arc>, +} +impl Quoter { + const DEFAULT: &'static str = r#" ----------- +< Thinking... > + ----------- + \ ^__^ + \ (oo)\_______ + (__)\ )\/\ + ||----w | + || ||"#; + + pub fn refresh(&mut self, runtime: &Runtime) { + let quote = self.quote.clone(); + + runtime.spawn(async move { + tracing::info!("fetching quote"); + + if let Ok(r) = + HTTP_CLIENT.get_with_reties("https://api.quotable.io/random", 3, 500).await + { + if let Ok(Quote { author, content }) = r.json::() { + if let Ok(mut q) = quote.write() { + *q = format!("{content}\n\n{author}"); + } + } + } + }); + } + + pub fn get(&self) -> Cow { + self.quote.read().map(|q| Cow::Owned(q.to_owned())).unwrap_or(Cow::Borrowed(Self::DEFAULT)) + } +} +impl Default for Quoter { + fn default() -> Self { + Self { quote: Arc::new(RwLock::new(Self::DEFAULT.into())) } + } +} + +#[derive(Debug, Deserialize)] +struct Quote { + author: String, + content: String, +} diff --git a/src/component/quoter.rs b/src/component/quoter.rs deleted file mode 100644 index 083cf4a..0000000 --- a/src/component/quoter.rs +++ /dev/null @@ -1,47 +0,0 @@ -// std -use std::{ - sync::{Arc, Mutex}, - thread, -}; -// serde -use serde::Deserialize; -// self -use super::fundamental::http::HttpClient; - -#[derive(Clone, Debug)] -pub struct Quoter { - pub quote: Arc>>, - http: HttpClient, -} -impl Quoter { - pub fn refresh(&mut self) { - let Quoter { quote, http } = self.to_owned(); - - thread::spawn(move || { - if let Ok(mut q) = quote.try_lock() { - tracing::info!("fetching quote"); - - if let Ok(r) = http.get("https://api.quotable.io/random") { - if let Ok(Quote { author, content }) = r.json::() { - *q = Some(format!("{content}\n\n{author}")); - } - } - } - }); - } -} -impl Default for Quoter { - fn default() -> Self { - let mut q = Self { quote: Arc::new(Mutex::new(None)), http: HttpClient::default() }; - - q.refresh(); - - q - } -} - -#[derive(Debug, Deserialize)] -struct Quote { - author: String, - content: String, -} diff --git a/src/component/setting.rs b/src/component/setting.rs index 3d97d74..5f8e70a 100644 --- a/src/component/setting.rs +++ b/src/component/setting.rs @@ -9,16 +9,16 @@ use crate::{component::openai::Model, prelude::*}; const APP: AppInfo = AppInfo { name: "AiR", author: "xavier@inv.cafe" }; #[derive(Debug, Default, Serialize, Deserialize)] -pub(crate) struct Setting { - pub(crate) general: General, - pub(crate) ai: Ai, +pub struct Setting { + pub general: General, + pub ai: Ai, } impl Setting { - pub(crate) fn path() -> Result { + pub fn path() -> Result { Ok(app_dirs2::get_app_root(AppDataType::UserConfig, &APP).map(|p| p.join(".airrc"))?) } - pub(crate) fn load() -> Result { + pub fn load() -> Result { let p = Self::path()?; tracing::info!("loading from {}", p.display()); @@ -26,7 +26,7 @@ impl Setting { Ok(toml::from_str(&fs::read_to_string(p)?)?) } - pub(crate) fn save(&self) -> Result<()> { + pub fn save(&self) -> Result<()> { let p = Self::path()?; tracing::info!("saving to {}", p.display()); @@ -36,9 +36,9 @@ impl Setting { } #[derive(Debug, Serialize, Deserialize)] -pub(crate) struct General { - pub(crate) font_size: f32, - pub(crate) hide_on_lost_focus: bool, +pub struct General { + pub font_size: f32, + pub hide_on_lost_focus: bool, } impl Default for General { fn default() -> Self { @@ -48,18 +48,19 @@ impl Default for General { // TODO: Support Google Gemini. #[derive(Clone, Debug, Serialize, Deserialize)] -pub(crate) struct Ai { - pub(crate) api_key: String, - pub(crate) model: String, - pub(crate) temperature: f32, +pub struct Ai { + // TODO: custom API endpoint. + pub api_key: String, + pub model: Model, + pub temperature: f32, } impl Ai { - pub(crate) fn temperature_rounded(&self) -> f32 { + pub fn temperature_rounded(&self) -> f32 { (self.temperature * 10.).round() / 10. } } impl Default for Ai { fn default() -> Self { - Self { api_key: Default::default(), model: Model::default().to_owned(), temperature: 0.7 } + Self { api_key: Default::default(), model: Model::default(), temperature: 0.7 } } } diff --git a/src/component/timer.rs b/src/component/timer.rs new file mode 100644 index 0000000..0a4b587 --- /dev/null +++ b/src/component/timer.rs @@ -0,0 +1,19 @@ +// std +use std::time::{Duration, Instant}; + +#[derive(Debug)] +pub struct Timer(Instant); +impl Timer { + pub fn duration(&mut self) -> Duration { + self.0.elapsed() + } + + pub fn refresh(&mut self) { + *self = Self::default(); + } +} +impl Default for Timer { + fn default() -> Self { + Self(Instant::now()) + } +} diff --git a/src/component/util.rs b/src/component/util.rs index eb29c25..4cee521 100644 --- a/src/component/util.rs +++ b/src/component/util.rs @@ -1,37 +1,3 @@ -// std -use std::time::{Duration, Instant}; - -#[derive(Debug)] -pub struct Timer { - duration: Duration, - instant: Option, -} -impl Timer { - pub fn refresh(&mut self) -> Duration { - if let Some(i) = self.instant { - self.duration + i.elapsed() - } else { - self.instant = Some(Instant::now()); - - self.duration - } - } - - pub fn reset(&mut self) { - *self = Self::default(); - } - - pub fn pause(&mut self) { - self.duration = self.instant.map(|i| i.elapsed()).unwrap_or_default(); - self.instant = None; - } -} -impl Default for Timer { - fn default() -> Self { - Self { duration: Default::default(), instant: Some(Instant::now()) } - } -} - pub fn price_rounded(value: f32) -> f32 { (value * 1_000_000.).round() / 1_000_000. } diff --git a/src/data.rs b/src/data.rs deleted file mode 100644 index b34ecac..0000000 --- a/src/data.rs +++ /dev/null @@ -1,81 +0,0 @@ -mod main; -use main::*; - -mod setting; -use setting::*; - -// crates.io -use eframe::egui::*; - -#[derive(Debug)] -pub struct Data { - panels: Panels, - main: Main, - setting: Setting, -} -impl Data { - fn set_font(ctx: &Context) { - let mut fonts = FontDefinitions::default(); - - fonts.font_data.insert( - "Monaspace Radon Var".into(), - FontData::from_static(include_bytes!("../asset/CascadiaCode.ttf")), - ); - fonts - .families - .entry(FontFamily::Proportional) - .or_default() - .insert(0, "Monaspace Radon Var".into()); - fonts - .families - .entry(FontFamily::Monospace) - .or_default() - .insert(0, "Monaspace Radon Var".into()); - - ctx.set_fonts(fonts); - } - - pub fn new(ctx: &Context) -> Self { - Self::set_font(ctx); - - let setting = Setting::default(); - let main = Main::from_setting(&setting); - - Self { panels: Default::default(), main, setting } - } - - pub fn draw(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.selectable_value(&mut self.panels, Panels::Main, "Main"); - ui.separator(); - ui.selectable_value(&mut self.panels, Panels::Setting, "Setting"); - ui.separator(); - }); - ui.separator(); - - match self.panels { - Panels::Main => self.main.draw(ui), - Panels::Setting => self.setting.draw(ui), - } - } - - pub fn refresh(&mut self) { - self.main.refresh(); - } - - pub fn save(&mut self) { - self.setting.save(); - } -} - -#[derive(Debug, PartialEq, Eq)] -enum Panels { - Main, - Setting, -} -impl Default for Panels { - fn default() -> Self { - Self::Main - // Self::Setting - } -} diff --git a/src/data/main.rs b/src/data/main.rs deleted file mode 100644 index b5f9f48..0000000 --- a/src/data/main.rs +++ /dev/null @@ -1,123 +0,0 @@ -// std -use std::{ - fmt::{Debug, Formatter, Result}, - sync::{Arc, Mutex}, -}; -// crates.io -use clipboard::*; -use eframe::egui::*; -use egui_commonmark::*; -// self -use crate::{ - component::{function::*, hotkey::*, openai::*, quoter::*, util}, - data::Setting, -}; - -const HINT: &str = r#" ----------- -< Thinking... > - ----------- - \ ^__^ - \ (oo)\_______ - (__)\ )\/\ - ||----w | - || ||"#; - -pub(super) struct Main { - hotkey: Hotkey, - clipboard: ClipboardContext, - quoter: Quoter, - openai: OpenAi, - input: String, - // TODO: Combine these things into one struct. - output: Arc>, - token_count: Arc>, -} -impl Main { - pub(super) fn from_setting(setting: &Setting) -> Self { - Self { - hotkey: Hotkey::register().unwrap(), - clipboard: ClipboardContext::new().unwrap(), - quoter: Quoter::default(), - openai: OpenAi::new(setting.ai.raw.clone()), - input: Default::default(), - output: Default::default(), - token_count: Default::default(), - } - } - - pub(super) fn draw(&mut self, ui: &mut Ui) { - if let Ok(f) = self.hotkey.try_recv() { - // TODO?: Move to hotkey. - match f { - Function::Polish => { - self.input = self.clipboard.get_contents().unwrap_or_default(); - - self.output.lock().unwrap().clear(); - // self.openai.chat( - // f.prompt(), - // &self.input, - // self.output.clone(), - // self.token_count.clone(), - // ); - }, - } - } - - let size = ui.available_size(); - - // Input. - ScrollArea::vertical().id_source("Input").max_height((size.y - 50.) / 2.).show(ui, |ui| { - ui.add_sized( - (size.x, ui.available_height()), - TextEdit::multiline(&mut self.input).hint_text( - self.quoter - .quote - .try_lock() - .ok() - .and_then(|q: std::sync::MutexGuard<'_, Option>| q.clone()) - .unwrap_or(HINT.into()), - ), - ); - }); - - // Separator. - ui.add_space(20.); - ui.separator(); - - // Usage. - ui.horizontal(|ui| { - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.spinner(); - ui.vertical(|ui| { - ui.add_space(4.5); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let (i, o) = self.token_count.lock().unwrap().to_owned(); - let (ip, op) = Model::price_of(&self.openai.setting.model); - - ui.label(format!( - "{} tokens (${:.6})", - i + o, - util::price_rounded(i as f32 * ip + o as f32 * op) - )); - }); - }); - }); - }); - - // Output. - CommonMarkViewer::new("Output").show_scrollable( - ui, - &mut CommonMarkCache::default(), - &self.output.lock().unwrap(), - ); - } - - pub(super) fn refresh(&mut self) { - self.quoter.refresh(); - } -} -impl Debug for Main { - fn fmt(&self, f: &mut Formatter<'_>) -> Result { - f.debug_struct("Main").field("hotkey", &self.hotkey).field("input", &self.input).finish() - } -} diff --git a/src/data/setting.rs b/src/data/setting.rs deleted file mode 100644 index 404bb15..0000000 --- a/src/data/setting.rs +++ /dev/null @@ -1,135 +0,0 @@ -// crates.io -use eframe::egui::*; -// self -use crate::{ - component::{ - openai::Model, - setting::{Ai as AiRaw, Setting as SettingRaw, *}, - }, - widget::ApiKey, -}; - -#[derive(Debug)] -pub struct Setting { - pub general: General, - pub ai: Ai, -} -impl Setting { - fn set_font_sizes(&self, ctx: &Context) { - ctx.style_mut(|s| { - s.text_styles.values_mut().for_each(|s| s.size = self.general.font_size); - }); - } - - pub fn draw(&mut self, ui: &mut Ui) { - ui.collapsing("General", |ui| { - Grid::new("General").show(ui, |ui| { - // Font size. - // TODO: Adjust widget's length. - ui.label("Font Size"); - if ui - .add( - Slider::new(&mut self.general.font_size, 9_f32..=16.) - .step_by(1.) - .fixed_decimals(0), - ) - .changed() - { - self.set_font_sizes(ui.ctx()); - } - ui.end_row(); - }); - }); - ui.collapsing("AI", |ui| { - Grid::new("AI").num_columns(2).striped(true).show(ui, |ui| { - // API key. - ui.label("API Key"); - let width = ui - .horizontal(|ui| { - let size = ui.available_size(); - let w_text = ui.add_sized( - (size.x - 56., size.y), - TextEdit::singleline(&mut self.ai.raw.api_key) - .password(self.ai.widget.visibility), - ); - - // TODO?: Persistent OpenAI client. - // if w_text.changed() {} - if ui.button(&self.ai.widget.label).clicked() { - self.ai.widget.clicked(); - } - - w_text.rect.width() - }) - .inner; - ui.spacing_mut().slider_width = width; - ui.end_row(); - - // Model. - ui.label("Model"); - ComboBox::from_id_source("Model").selected_text(&self.ai.raw.model).show_ui( - ui, - |ui| { - Model::all().iter().for_each(|m| { - ui.selectable_value(&mut self.ai.raw.model, (*m).to_owned(), *m); - }); - }, - ); - ui.end_row(); - - // Temperature. - ui.label("Temperature"); - ui.add( - Slider::new(&mut self.ai.raw.temperature, 0_f32..=2.) - .fixed_decimals(1) - .step_by(0.1), - ); - ui.end_row(); - }); - }); - ui.collapsing("Hotkey", |_ui| {}); - } - - pub fn save(&mut self) { - if let Err(e) = SettingRaw::from(&*self).save() { - tracing::error!("{e:?}"); - } - } -} -impl Default for Setting { - fn default() -> Self { - SettingRaw::load().unwrap_or_default().into() - } -} -impl From for Setting { - fn from(v: SettingRaw) -> Self { - Self { general: v.general, ai: v.ai.into() } - } -} -impl From<&Setting> for SettingRaw { - fn from(v: &Setting) -> Self { - Self { - general: General { - font_size: v.general.font_size, - hide_on_lost_focus: v.general.hide_on_lost_focus, - }, - ai: (&v.ai).into(), - } - } -} - -#[derive(Debug)] -pub struct Ai { - pub raw: AiRaw, - pub widget: ApiKey, -} -impl From for Ai { - fn from(v: AiRaw) -> Self { - Self { raw: v, widget: ApiKey::default() } - } -} -impl From<&Ai> for AiRaw { - fn from(v: &Ai) -> Self { - v.raw.clone() - } -} diff --git a/src/error.rs b/src/error.rs index 6b056a6..bc79071 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,7 +10,19 @@ pub enum Error { #[error(transparent)] Eframe(#[from] eframe::Error), #[error(transparent)] + Enigo(#[from] EnigoError), + #[error(transparent)] GlobalHotKey(#[from] global_hotkey::Error), #[error(transparent)] + OpenAi(#[from] async_openai::error::OpenAIError), + #[error(transparent)] Toml(#[from] toml::de::Error), } + +#[derive(Debug, thiserror::Error)] +pub enum EnigoError { + #[error(transparent)] + Input(#[from] enigo::InputError), + #[error(transparent)] + NewCon(#[from] enigo::NewConError), +} diff --git a/src/main.rs b/src/main.rs index 28095e9..90bf0a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -//! AI Rust. +//! AI with Rust. // TODO: check this. // hide console window on Windows in release @@ -11,11 +11,10 @@ mod air; mod component; -mod data; mod error; mod os; mod service; -mod widget; +mod ui; mod prelude { pub type Result = std::result::Result; diff --git a/src/os.rs b/src/os.rs index 8edb856..8b4ca02 100644 --- a/src/os.rs +++ b/src/os.rs @@ -1,11 +1,19 @@ #[cfg(target_os = "macos")] mod macos; pub trait Accessibility { + fn get_selected_text() -> Option { + get_selected_text::get_selected_text().ok() + } +} + +pub trait AppKit { + // #[deprecated(note = "use `ViewportCommand` instead")] fn hide(); + // #[deprecated(note = "use `ViewportCommand` instead")] fn unhide(); - fn selected_text() -> Option; + fn set_move_to_active_space(); } #[derive(Debug)] diff --git a/src/os/macos.rs b/src/os/macos.rs index 42476f0..f4a3aef 100644 --- a/src/os/macos.rs +++ b/src/os/macos.rs @@ -1,14 +1,61 @@ // crates.io -use accessibility::{AXAttribute, AXUIElement, AXUIElementAttributes}; -use accessibility_sys::{ - kAXFocusedUIElementAttribute, kAXFocusedWindowAttribute, kAXSelectedTextAttribute, -}; -use core_foundation::{base::CFType, string::CFString}; -use objc2_app_kit::{NSRunningApplication, NSWorkspace}; +// use accessibility::{AXAttribute, AXUIElement, AXUIElementAttributes}; +// use accessibility_sys::{ +// kAXFocusedUIElementAttribute, kAXFocusedWindowAttribute, kAXSelectedTextAttribute, +// }; +// use core_foundation::{ +// base::{CFType, ToVoid}, +// string::CFString, +// }; +use objc2_app_kit::{NSApplication, NSRunningApplication, NSWindowCollectionBehavior}; +use objc2_foundation::MainThreadMarker; // self use super::*; impl Accessibility for Os { + // fn selected_text() -> Option { + // fn attr(attr: &'static str) -> AXAttribute { + // AXAttribute::new(&CFString::from_static_string(attr)) + // } + // + // fn try_get_focus_element(ax_ui_element: &AXUIElement) -> Option { + // if let Ok(e) = ax_ui_element.attribute(&attr(kAXFocusedUIElementAttribute)) { + // return e.downcast_into(); + // } + // + // ax_ui_element + // .children() + // .ok() + // .and_then(|es| es.iter().find_map(|e| try_get_focus_element(&e))) + // } + // + // let pid = unsafe { + // let workspace = NSWorkspace::sharedWorkspace(); + // let app = workspace.frontmostApplication()?; + // + // app.processIdentifier() + // }; + // let root = AXUIElement::application(pid); + // let window = root + // .attribute(&attr(kAXFocusedWindowAttribute)) + // .unwrap() + // .downcast_into::()?; + // + // tracing::debug!("window: {window:?}"); + // + // let element = try_get_focus_element(&window)?; + // + // tracing::debug!("element: {window:?}"); + // + // element + // .attribute(&attr(kAXSelectedTextAttribute)) + // .ok() + // .and_then(|t| t.downcast_into::()) + // .map(|t| t.to_string()) + // } +} + +impl AppKit for Os { fn hide() { unsafe { NSRunningApplication::currentApplication().hide(); @@ -21,60 +68,17 @@ impl Accessibility for Os { } } - fn selected_text() -> Option { - let pid = unsafe { - let workspace = NSWorkspace::sharedWorkspace(); - let app = workspace.frontmostApplication().unwrap(); - dbg!(&app); - - app.processIdentifier() - }; - - fn attr(attr: &'static str) -> AXAttribute { - AXAttribute::new(&CFString::from_static_string(attr)) - } - - fn try_get_focus_element(ax_ui_element: &AXUIElement) -> Option { - if let Ok(e) = ax_ui_element.attribute(&attr(kAXFocusedUIElementAttribute)) { - return e.downcast_into(); - } + fn set_move_to_active_space() { + unsafe { + // let app: *mut AnyObject = + // objc2::msg_send![objc2::class!(NSApplication), sharedApplication]; + // let window: *mut AnyObject = objc2::msg_send![app, mainWindow]; + // let _: () = objc2::msg_send![window, setCollectionBehavior: 1_u64<<1]; - ax_ui_element - .children() - .ok() - .and_then(|es| es.iter().find_map(|e| try_get_focus_element(&e))) + NSApplication::sharedApplication(MainThreadMarker::new_unchecked()) + .mainWindow() + .expect("main window must be available") + .setCollectionBehavior(NSWindowCollectionBehavior::MoveToActiveSpace); } - - let root = AXUIElement::application(pid); - dbg!(&root); - - let window = root - .attribute(&attr(kAXFocusedWindowAttribute)) - .unwrap() - .downcast_into::() - .unwrap(); - dbg!(&window); - - let element = try_get_focus_element(&window); - dbg!(element); - - // fn attribute(attr: &'static str) -> AXAttribute { - // AXAttribute::new(&CFString::from_static_string(attr)) - // } - - // match AXUIElement::system_wide().attribute(&attribute(kAXFocusedUIElementAttribute)) { - // Ok(ui) => { - // match ui - // .downcast_into::()? - // .attribute(&attribute(kAXSelectedTextAttribute)) - // { - // Ok(text) => return Some(text.downcast_into::()?.to_string()), - // Err(e) => tracing::error!("get `kAXSelectedTextAttribute` returns `{e}`"), - // } - // }, - // Err(e) => tracing::error!("get `kAXFocusedUIElementAttribute` returns `{e}`"), - // } - - None } } diff --git a/src/service.rs b/src/service.rs index e69de29..d11f008 100644 --- a/src/service.rs +++ b/src/service.rs @@ -0,0 +1,45 @@ +mod hotkey; +use hotkey::Hotkey; + +// TODO?: Init trait. + +// std +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::Duration, +}; +// crates.io +use eframe::egui::Context; + +#[derive(Debug)] +pub struct Services { + // TODO: https://github.com/emilk/egui/issues/4468. + pub to_hidden: Arc, + pub hotkey: Hotkey, +} +impl Services { + pub fn init(ctx: &Context) -> Self { + let to_hidden = { + let ctx: Context = ctx.to_owned(); + let to_hidden = Arc::new(AtomicBool::new(false)); + let to_hidden_ = to_hidden.clone(); + + thread::spawn(move || { + while !ctx.input(|i| i.focused) { + thread::sleep(Duration::from_millis(50)); + } + + to_hidden_.store(false, Ordering::SeqCst); + }); + + to_hidden + }; + let hotkey = Hotkey::init(ctx, to_hidden.clone()); + + Self { to_hidden, hotkey } + } +} diff --git a/src/service/hotkey.rs b/src/service/hotkey.rs new file mode 100644 index 0000000..5c440e8 --- /dev/null +++ b/src/service/hotkey.rs @@ -0,0 +1,90 @@ +// std +use std::{ + fmt::{Debug, Formatter, Result as FmtResult}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver}, + Arc, + }, + thread, +}; +// crates.io +use arboard::Clipboard; +use eframe::egui::{Context, ViewportCommand}; +use global_hotkey::{ + hotkey::{Code, HotKey, Modifiers}, + GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState, +}; +// self +use crate::{component::function::Function, os::*}; + +pub struct Hotkey { + pub clipboard: Clipboard, + pub rx: Receiver, +} +impl Hotkey { + pub fn init(ctx: &Context, to_hidden: Arc) -> Self { + let ctx = ctx.to_owned(); + let clipboard = Clipboard::new().expect("clipboard must be available"); + let (tx, rx) = mpsc::channel(); + let manager = GlobalHotKeyManager::new().expect("hotkey manager must be created"); + let receiver = GlobalHotKeyEvent::receiver(); + // Hotkeys. + let hk_polish = HotKey::new(Some(Modifiers::CONTROL), Code::KeyU); + let hk_polish_id = hk_polish.id(); + + manager.register_all(&[hk_polish]).expect("hotkey must be registered"); + + thread::spawn(move || { + // The manager need to be kept alive during the whole program life. + let _manager = manager; + let mut clipboard = Clipboard::new().expect("clipboard must be available"); + + loop { + // Block the thread until a hotkey event is received. + match receiver.recv() { + Ok(e) => { + // We don't care about the release event. + if let HotKeyState::Pressed = e.state { + tracing::info!("receive hotkey event {e:?}"); + + to_hidden.store(false, Ordering::SeqCst); + // TODO: https://github.com/emilk/egui/discussions/4635. + // ctx.send_viewport_cmd(ViewportCommand::Minimized(false)); + Os::unhide(); + + match e.id { + i if i == hk_polish_id => { + if let Some(t) = Os::get_selected_text() { + clipboard.set_text(t).expect("clipboard must be set"); + } + + tx.send(Function::Polish).expect("hotkey event must be sent"); + }, + _ => tracing::error!("unknown hotkey id {e:?}"), + } + + // Give some time for the system to unhide and then focus. + // Since `get_selected_text`` is slow, we don't need this for now. + // thread::sleep(Duration::from_millis(50)); + + ctx.send_viewport_cmd(ViewportCommand::Focus); + } + }, + Err(e) => panic!("failed to receive hotkey event {e:?}"), + } + } + }); + + Self { clipboard, rx } + } + + pub fn try_recv(&mut self) -> Option<(Function, String)> { + self.rx.try_recv().ok().map(|f| (f, self.clipboard.get_text().unwrap_or_default())) + } +} +impl Debug for Hotkey { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "Hotkey {{ clipboard: .., rx: {:?} }}", self.rx) + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..f771038 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,49 @@ +mod panel; +use panel::{Chat, Panel, Setting}; + +mod util; + +// crates.io +use eframe::egui::*; +// self +use crate::air::AiRContext; + +trait UiT { + fn draw(&mut self, ui: &mut Ui, ctx: &mut AiRContext); +} + +#[derive(Debug, Default)] +pub struct Uis { + pub focused_panel: Panel, + pub chat: Chat, + pub setting: Setting, +} +impl Uis { + pub fn init() -> Self { + Default::default() + } + + pub fn draw(&mut self, mut ctx: AiRContext) { + CentralPanel::default().frame(util::transparent_frame(ctx.egui_ctx)).show( + ctx.egui_ctx, + |ui| { + ui.horizontal(|ui| { + ui.selectable_value(&mut self.focused_panel, Panel::Chat, Panel::Chat.name()); + ui.separator(); + ui.selectable_value( + &mut self.focused_panel, + Panel::Setting, + Panel::Setting.name(), + ); + ui.separator(); + }); + ui.separator(); + + match self.focused_panel { + Panel::Chat => self.chat.draw(ui, &mut ctx), + Panel::Setting => self.setting.draw(ui, &mut ctx), + } + }, + ); + } +} diff --git a/src/ui/panel.rs b/src/ui/panel.rs new file mode 100644 index 0000000..3c22c1e --- /dev/null +++ b/src/ui/panel.rs @@ -0,0 +1,24 @@ +mod chat; +pub use chat::Chat; + +mod setting; +pub use setting::Setting; + +#[derive(Debug, PartialEq, Eq)] +pub enum Panel { + Chat, + Setting, +} +impl Panel { + pub fn name(&self) -> &str { + match self { + Self::Chat => "Chat", + Self::Setting => "Setting", + } + } +} +impl Default for Panel { + fn default() -> Self { + Self::Chat + } +} diff --git a/src/ui/panel/chat.rs b/src/ui/panel/chat.rs new file mode 100644 index 0000000..83c896b --- /dev/null +++ b/src/ui/panel/chat.rs @@ -0,0 +1,73 @@ +// crates.io +use eframe::egui::*; +use egui_commonmark::*; +// self +use super::super::UiT; +use crate::{air::AiRContext, component::util}; + +#[derive(Debug, Default)] +pub struct Chat { + // TODO: use widgets instead. + pub input: String, + pub output: String, +} +impl UiT for Chat { + fn draw(&mut self, ui: &mut Ui, ctx: &mut AiRContext) { + if let Some((func, input)) = ctx.services.hotkey.try_recv() { + // TODO: focus on the chat panel. + + self.input = input; + + ctx.components + .openai + .chat(ctx.runtime, func.prompt(), &self.input) + .expect("chat must succeed"); + } + if let Ok(output) = ctx.components.openai.output.try_lock() { + output.clone_into(&mut self.output); + } + + let size = ui.available_size(); + + // Input. + ScrollArea::vertical().id_source("Input").max_height((size.y - 50.) / 2.).show(ui, |ui| { + ui.add_sized( + (size.x, ui.available_height()), + TextEdit::multiline(&mut self.input).hint_text(ctx.components.quote.get()), + ); + }); + + // Separator. + ui.add_space(20.); + ui.separator(); + + // Usage. + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // TODO: when to show the spinner. + ui.spinner(); + ui.vertical(|ui| { + ui.add_space(4.5); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // let (i, o) = self.token_count.lock().unwrap().to_owned(); + let (i, o) = (0, 0); + let (ip, op) = ctx.components.setting.ai.model.prices(); + + ui.label(format!( + "{} tokens (${:.6})", + i + o, + util::price_rounded(i as f32 * ip + o as f32 * op) + )); + }); + }); + }); + }); + + // Output. + CommonMarkViewer::new("Output").show_scrollable( + ui, + &mut CommonMarkCache::default(), + &self.output, + ); + } +} diff --git a/src/ui/panel/setting.rs b/src/ui/panel/setting.rs new file mode 100644 index 0000000..97d0d38 --- /dev/null +++ b/src/ui/panel/setting.rs @@ -0,0 +1,113 @@ +// crates.io +use eframe::egui::*; +// self +use super::super::UiT; +use crate::{air::AiRContext, component::openai::Model}; + +#[derive(Debug, Default)] +pub struct Setting { + pub api_key_widget: ApiKeyWidget, +} +impl Setting { + fn set_font_sizes(&self, ctx: &AiRContext) { + ctx.egui_ctx.style_mut(|s| { + s.text_styles + .values_mut() + .for_each(|s| s.size = ctx.components.setting.general.font_size); + }); + } +} +impl UiT for Setting { + fn draw(&mut self, ui: &mut Ui, ctx: &mut AiRContext) { + ui.collapsing("General", |ui| { + Grid::new("General").show(ui, |ui| { + // Font size. + // TODO: adjust api_key_widget's length. + ui.label("Font Size"); + if ui + .add( + Slider::new(&mut ctx.components.setting.general.font_size, 9_f32..=16.) + .step_by(1.) + .fixed_decimals(0), + ) + .changed() + { + self.set_font_sizes(ctx); + } + ui.end_row(); + }); + }); + ui.collapsing("AI", |ui| { + Grid::new("AI").num_columns(2).striped(true).show(ui, |ui| { + // API key. + ui.label("API Key"); + let width = ui + .horizontal(|ui| { + let size = ui.available_size(); + let w_text = ui.add_sized( + (size.x - 56., size.y), + TextEdit::singleline(&mut ctx.components.setting.ai.api_key) + .password(self.api_key_widget.visibility), + ); + + // TODO?: persistent OpenAI client. + // if w_text.changed() {} + if ui.button(&self.api_key_widget.label).clicked() { + self.api_key_widget.clicked(); + } + + w_text.rect.width() + }) + .inner; + ui.spacing_mut().slider_width = width; + ui.end_row(); + + // Model. + ui.label("Model"); + ComboBox::from_id_source("Model") + .selected_text(&ctx.components.setting.ai.model) + .show_ui(ui, |ui| { + Model::all().iter().for_each(|m| { + ui.selectable_value( + &mut ctx.components.setting.ai.model, + m.to_owned(), + m.as_str(), + ); + }); + }); + ui.end_row(); + + // Temperature. + ui.label("Temperature"); + ui.add( + Slider::new(&mut ctx.components.setting.ai.temperature, 0_f32..=2.) + .fixed_decimals(1) + .step_by(0.1), + ); + ui.end_row(); + }); + }); + // ui.collapsing("Hotkey", |_ui| {}); + } +} + +#[derive(Debug)] +pub struct ApiKeyWidget { + pub label: String, + pub visibility: bool, +} +impl ApiKeyWidget { + pub fn clicked(&mut self) { + self.label = match self.label.as_str() { + "show" => "hide".into(), + "hide" => "show".into(), + _ => unreachable!(), + }; + self.visibility = !self.visibility; + } +} +impl Default for ApiKeyWidget { + fn default() -> Self { + Self { label: "show".into(), visibility: true } + } +} diff --git a/src/ui/util.rs b/src/ui/util.rs new file mode 100644 index 0000000..b733765 --- /dev/null +++ b/src/ui/util.rs @@ -0,0 +1,6 @@ +// crates.io +use eframe::egui::*; + +pub fn transparent_frame(ctx: &Context) -> Frame { + Frame::central_panel(&ctx.style()).fill(Color32::TRANSPARENT) +} diff --git a/src/widget.rs b/src/widget.rs deleted file mode 100644 index 9c3c4fd..0000000 --- a/src/widget.rs +++ /dev/null @@ -1,20 +0,0 @@ -#[derive(Debug)] -pub struct ApiKey { - pub label: String, - pub visibility: bool, -} -impl ApiKey { - pub fn clicked(&mut self) { - self.label = match self.label.as_str() { - "show" => "hide".into(), - "hide" => "show".into(), - _ => unreachable!(), - }; - self.visibility = !self.visibility; - } -} -impl Default for ApiKey { - fn default() -> Self { - Self { label: "show".into(), visibility: true } - } -}