├── .gitignore ├── img ├── main_page.png ├── about_page.png ├── config_page.png ├── tmrcnf_page.png └── theme_selection.png ├── tomotroid.rc ├── assets ├── audio │ ├── tick.ogg │ ├── alert-work.ogg │ ├── alert-long-break.ogg │ └── alert-short-break.ogg ├── icons │ ├── logo.ico │ ├── logo.png │ ├── check.svg │ ├── start.svg │ ├── info.svg │ ├── minimize.svg │ ├── clock.svg │ ├── pause.svg │ ├── close.svg │ ├── skip.svg │ ├── pallette.svg │ ├── muted.svg │ ├── mute.svg │ └── gear.svg ├── fonts │ ├── Lato-Regular.ttf │ └── RobotoMono-Light.ttf ├── tomotroid.desktop ├── themes │ ├── ayu.json │ ├── dva.json │ ├── gruvbox.json │ ├── nord.json │ ├── dracula.json │ ├── github.json │ ├── monokai.json │ ├── spandex.json │ ├── andromeda.json │ ├── graphite.json │ ├── one-dark.json │ ├── pomotroid.json │ ├── rangitoto.json │ ├── synthwave.json │ ├── city-lights.json │ ├── solarized-light.json │ ├── tokyo-night.json │ └── popping-and-locking.json ├── default-preferences.json └── logo.svg ├── .github └── FUNDING.yml ├── .cargo └── config.toml ├── ui ├── hyperlink.slint ├── slidover.slint ├── tooltip.slint ├── themeconfig.slint ├── circular-progress.slint ├── about.slint ├── globals.slint ├── slider.slint ├── timerconfig.slint ├── tabcontainer.slint ├── borderless-window.slint ├── config.slint └── appwindow.slint ├── LICENSE ├── Cargo.toml ├── README.MD └── src ├── setup.rs ├── main.rs └── settings.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /img/main_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/img/main_page.png -------------------------------------------------------------------------------- /img/about_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/img/about_page.png -------------------------------------------------------------------------------- /img/config_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/img/config_page.png -------------------------------------------------------------------------------- /img/tmrcnf_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/img/tmrcnf_page.png -------------------------------------------------------------------------------- /tomotroid.rc: -------------------------------------------------------------------------------- 1 | exe-icon ICON "assets/icons/logo.ico" 2 | logo-icon ICON "assets/icons/logo.ico" -------------------------------------------------------------------------------- /assets/audio/tick.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/assets/audio/tick.ogg -------------------------------------------------------------------------------- /assets/icons/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/assets/icons/logo.ico -------------------------------------------------------------------------------- /assets/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/assets/icons/logo.png -------------------------------------------------------------------------------- /img/theme_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/img/theme_selection.png -------------------------------------------------------------------------------- /assets/audio/alert-work.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/assets/audio/alert-work.ogg -------------------------------------------------------------------------------- /assets/fonts/Lato-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/assets/fonts/Lato-Regular.ttf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | ko_fi: vadoola 3 | buy_me_a_coffee: vadoola 4 | -------------------------------------------------------------------------------- /assets/audio/alert-long-break.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/assets/audio/alert-long-break.ogg -------------------------------------------------------------------------------- /assets/audio/alert-short-break.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/assets/audio/alert-short-break.ogg -------------------------------------------------------------------------------- /assets/fonts/RobotoMono-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vadoola/Tomotroid/HEAD/assets/fonts/RobotoMono-Light.ttf -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | #this below change to the windows stack size is to work around a bug in Slint 2 | #which causes a stack overflow on Windows in debug builds 3 | #See Tomotroid Issue #82 for further details 4 | [target.x86_64-pc-windows-msvc] 5 | rustflags = [ 6 | "-C", "link-arg=/STACK:2000000" 7 | ] -------------------------------------------------------------------------------- /assets/tomotroid.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Encoding=UTF-8 3 | Version=1.0 4 | Type=Application 5 | Terminal=false 6 | Exec=/usr/bin/tomotroid 7 | Path=/usr/bin/ 8 | Name=Tomotroid 9 | GenericName=Pomodoro Timer 10 | #Icon=/where/do/icons/go/logo.png 11 | Hidden=false 12 | Categories=Office; Utility 13 | SingleMainWindow=true 14 | -------------------------------------------------------------------------------- /assets/icons/check.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /assets/icons/start.svg: -------------------------------------------------------------------------------- 1 | 14 | 18 | -------------------------------------------------------------------------------- /assets/icons/info.svg: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 | -------------------------------------------------------------------------------- /assets/themes/ayu.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ayu", 3 | "colors": { 4 | "--color-long-round": "#5CCFE6", 5 | "--color-short-round": "#BAE67E", 6 | "--color-focus-round": "#F28779", 7 | "--color-background": "#1F2430", 8 | "--color-background-light": "#2a3546", 9 | "--color-background-lightest": "#707a8c", 10 | "--color-foreground": "#CBCCC6", 11 | "--color-foreground-darker": "#CBCCC6", 12 | "--color-foreground-darkest": "#5ccfe6", 13 | "--color-accent": "#FFCC66" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/dva.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "D.Va", 3 | "colors": { 4 | "--color-long-round": "#26adff", 5 | "--color-short-round": "#0de2c9", 6 | "--color-focus-round": "#ec57fd", 7 | "--color-background": "#2e2733", 8 | "--color-background-light": "#35303a", 9 | "--color-background-lightest": "#aba3b3", 10 | "--color-foreground": "#f2f8f7", 11 | "--color-foreground-darker": "#e8d3ea", 12 | "--color-foreground-darkest": "#d5bbd8", 13 | "--color-accent": "#0de2c9" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/gruvbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Gruvbox", 3 | "colors": { 4 | "--color-long-round": "#83A598", 5 | "--color-short-round": "#B8BB26", 6 | "--color-focus-round": "#FB4934", 7 | "--color-background": "#282828", 8 | "--color-background-light": "#3c3836", 9 | "--color-background-lightest": "#bdae93", 10 | "--color-foreground": "#ebdbb2", 11 | "--color-foreground-darker": "#bdae93", 12 | "--color-foreground-darkest": "#928374", 13 | "--color-accent": "#FABD2F" 14 | } 15 | } -------------------------------------------------------------------------------- /assets/themes/nord.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nord", 3 | "colors": { 4 | "--color-long-round": "#5E81AC", 5 | "--color-short-round": "#8FBCBB", 6 | "--color-focus-round": "#B48EAD", 7 | "--color-background": "#2e3440", 8 | "--color-background-light": "#3b4252", 9 | "--color-background-lightest": "#616E88", 10 | "--color-foreground": "#d8dee9", 11 | "--color-foreground-darker": "#8FBCBB", 12 | "--color-foreground-darkest": "#88C0D0", 13 | "--color-accent": "#A3BE8C" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/dracula.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dracula", 3 | "colors": { 4 | "--color-long-round": "#8be9fd", 5 | "--color-short-round": "#50fa7b", 6 | "--color-focus-round": "#ff5555", 7 | "--color-background": "#282a36", 8 | "--color-background-light": "#363846", 9 | "--color-background-lightest": "#6272a4", 10 | "--color-foreground": "#f8f8f2", 11 | "--color-foreground-darker": "#ffb86c", 12 | "--color-foreground-darkest": "#ff79c6", 13 | "--color-accent": "#bd93f9" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GitHub", 3 | "colors": { 4 | "--color-long-round": "#6F42C1", 5 | "--color-short-round": "#005CC5", 6 | "--color-focus-round": "#CD3131", 7 | "--color-background": "#FFFFFF", 8 | "--color-background-light": "#f6f8fa", 9 | "--color-background-lightest": "#24292e", 10 | "--color-foreground": "#24292e", 11 | "--color-foreground-darker": "#586069", 12 | "--color-foreground-darkest": "#80878e", 13 | "--color-accent": "#005CC5" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/monokai.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Monokai", 3 | "colors": { 4 | "--color-long-round": "#66d9ef", 5 | "--color-short-round": "#a6e22e", 6 | "--color-focus-round": "#f92672", 7 | "--color-background": "#272822", 8 | "--color-background-light": "#393a34", 9 | "--color-background-lightest": "#9c9e92", 10 | "--color-foreground": "#FDF9F3", 11 | "--color-foreground-darker": "#dad2c6", 12 | "--color-foreground-darkest": "#d8cbb6", 13 | "--color-accent": "#AE81FF" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/spandex.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Spandex", 3 | "colors": { 4 | "--color-long-round": "#00dcff", 5 | "--color-short-round": "#09ffbb", 6 | "--color-focus-round": "#c92fdc", 7 | "--color-background": "#181a1b", 8 | "--color-background-light": "#212425", 9 | "--color-background-lightest": "#5e696d", 10 | "--color-foreground": "#e0e3e6", 11 | "--color-foreground-darker": "#b9bdc1", 12 | "--color-foreground-darkest": "#9da2a7", 13 | "--color-accent": "#f0ff09" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/andromeda.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Andromeda", 3 | "colors": { 4 | "--color-long-round": "#C74DED", 5 | "--color-short-round": "#00E8C6", 6 | "--color-focus-round": "#EE5D43", 7 | "--color-background": "#23262E", 8 | "--color-background-light": "#2e323d", 9 | "--color-background-lightest": "#746f77", 10 | "--color-foreground": "#d5ced9", 11 | "--color-foreground-darker": "#746f77", 12 | "--color-foreground-darkest": "#CD9731", 13 | "--color-accent": "#FFE66D" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/graphite.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Graphite", 3 | "colors": { 4 | "--color-long-round": "#505154", 5 | "--color-short-round": "#505154", 6 | "--color-focus-round": "#505154", 7 | "--color-background": "#ebebea", 8 | "--color-background-light": "#fcfcfc", 9 | "--color-background-lightest": "#adafb1", 10 | "--color-foreground": "#27292d", 11 | "--color-foreground-darker": "#4a4e56", 12 | "--color-foreground-darkest": "#656a75", 13 | "--color-accent": "#08568c" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/one-dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "One Dark Pro", 3 | "colors": { 4 | "--color-long-round": "#61AFEF", 5 | "--color-short-round": "#98C379", 6 | "--color-focus-round": "#E06C75", 7 | "--color-background": "#282c34", 8 | "--color-background-light": "#3b4048", 9 | "--color-background-lightest": "#7f848e", 10 | "--color-foreground": "#abb2bf", 11 | "--color-foreground-darker": "#abb2bf", 12 | "--color-foreground-darkest": "#E5C07B", 13 | "--color-accent": "#C678DD" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/pomotroid.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pomotroid", 3 | "colors": { 4 | "--color-long-round": "#0bbddb", 5 | "--color-short-round": "#05ec8c", 6 | "--color-focus-round": "#ff4e4d", 7 | "--color-background": "#2f384b", 8 | "--color-background-light": "#3d4457", 9 | "--color-background-lightest": "#9ca5b5", 10 | "--color-foreground": "#f6f2eb", 11 | "--color-foreground-darker": "#c0c9da", 12 | "--color-foreground-darkest": "#dbe1ef", 13 | "--color-accent": "#05ec8c" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/rangitoto.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Rangitoto", 3 | "colors": { 4 | "--color-long-round": "#af486d", 5 | "--color-short-round": "#719002", 6 | "--color-focus-round": "#3c73b8", 7 | "--color-background": "#1a191e", 8 | "--color-background-light": "#343132", 9 | "--color-background-lightest": "#837c7e", 10 | "--color-foreground": "#dfdfd7", 11 | "--color-foreground-darker": "#bec0c0", 12 | "--color-foreground-darkest": "#adadae", 13 | "--color-accent": "#cd7a0c" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/synthwave.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Synthwave", 3 | "colors": { 4 | "--color-long-round": "#36F9F6", 5 | "--color-short-round": "#72F1B8", 6 | "--color-focus-round": "#FF7EDB", 7 | "--color-background": "#262335", 8 | "--color-background-light": "#372d4b", 9 | "--color-background-lightest": "#495495", 10 | "--color-foreground": "#e0e3e6", 11 | "--color-foreground-darker": "#b893ce", 12 | "--color-foreground-darkest": "#DD5500", 13 | "--color-accent": "#CD9731" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/city-lights.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "City Lights", 3 | "colors": { 4 | "--color-long-round": "#6796E6", 5 | "--color-short-round": "#33CED8", 6 | "--color-focus-round": "#E27E8D", 7 | "--color-background": "#1d252c", 8 | "--color-background-light": "#28313a", 9 | "--color-background-lightest": "#718CA1", 10 | "--color-foreground": "#b7c5d3", 11 | "--color-foreground-darker": "#718CA1", 12 | "--color-foreground-darkest": "#718CA1", 13 | "--color-accent": "#EBBF83" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/solarized-light.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Solarized Light", 3 | "colors": { 4 | "--color-long-round": "#2AA198", 5 | "--color-short-round": "#859900", 6 | "--color-focus-round": "#B58900", 7 | "--color-background": "#FDF6E3", 8 | "--color-background-light": "#EEE8D5", 9 | "--color-background-lightest": "#657b83", 10 | "--color-foreground": "#586e75", 11 | "--color-foreground-darker": "#93A1A1", 12 | "--color-foreground-darkest": "#AC9D57", 13 | "--color-accent": "#268BD2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/tokyo-night.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tokyo Night Storm", 3 | "colors": { 4 | "--color-long-round": "#7AA2F7", 5 | "--color-short-round": "#73DACA", 6 | "--color-focus-round": "#F7768E", 7 | "--color-background": "#24283b", 8 | "--color-background-light": "#1b1e2e", 9 | "--color-background-lightest": "#9AA5CE", 10 | "--color-foreground": "#c0caf5", 11 | "--color-foreground-darker": "#9AA5CE", 12 | "--color-foreground-darkest": "#89DDFF", 13 | "--color-accent": "#9D7CD8" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/themes/popping-and-locking.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Popping and Locking", 3 | "colors": { 4 | "--color-long-round": "#458588", 5 | "--color-short-round": "#7ec16e", 6 | "--color-focus-round": "#f42c3e", 7 | "--color-background": "#21222d", 8 | "--color-background-light": "#313242", 9 | "--color-background-lightest": "#7f7d7a", 10 | "--color-foreground": "#f2e5bc", 11 | "--color-foreground-darker": "#f9f5d7", 12 | "--color-foreground-darkest": "#ebdbb2", 13 | "--color-accent": "#d79921" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/icons/minimize.svg: -------------------------------------------------------------------------------- 1 | 15 | 26 | -------------------------------------------------------------------------------- /ui/hyperlink.slint: -------------------------------------------------------------------------------- 1 | export global HLClick { 2 | pure callback hl-clicked(string); 3 | } 4 | 5 | export component HyperLink inherits Text { 6 | in property link-text; 7 | in property url; 8 | in property link-color; 9 | in property hvr-color; 10 | 11 | text: root.link-text; 12 | 13 | hl-ta := TouchArea { 14 | clicked => { HLClick.hl-clicked(root.url)} 15 | } 16 | 17 | states [ 18 | has-hvr when hl-ta.has-hover: { 19 | color: root.hvr-color; 20 | } 21 | not-hvr when !hl-ta.has-hover: { 22 | color: root.link-color; 23 | } 24 | ] 25 | 26 | } -------------------------------------------------------------------------------- /assets/default-preferences.json: -------------------------------------------------------------------------------- 1 | { 2 | "alwaysOnTop": false, 3 | "autoStartBreakTimer": true, 4 | "autoStartWorkTimer": true, 5 | "breakAlwaysOnTop": false, 6 | "globalShortcuts": { 7 | "call-timer-reset": "Control+F2", 8 | "call-timer-skip": "Control+F3", 9 | "call-timer-toggle": "Control+F1" 10 | }, 11 | "minToTray": true, 12 | "minToTrayOnClose": false, 13 | "notifications": true, 14 | "theme": "Rangitoto", 15 | "tickSounds": false, 16 | "tickSoundsDuringBreak": true, 17 | "timeLongBreak": 15, 18 | "timeShortBreak": 5, 19 | "timeWork": 25, 20 | "volume": 100, 21 | "workRounds": 4 22 | } -------------------------------------------------------------------------------- /assets/icons/clock.svg: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /assets/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 14 | 25 | 36 | -------------------------------------------------------------------------------- /assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 14 | 25 | 36 | -------------------------------------------------------------------------------- /assets/icons/skip.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 29 | 30 | -------------------------------------------------------------------------------- /assets/icons/pallette.svg: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 | -------------------------------------------------------------------------------- /assets/icons/muted.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /assets/icons/mute.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 21 | 22 | -------------------------------------------------------------------------------- /assets/icons/gear.svg: -------------------------------------------------------------------------------- 1 | 14 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Christopher Murphy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ui/slidover.slint: -------------------------------------------------------------------------------- 1 | import { VerticalBox , HorizontalBox, Button } from "std-widgets.slint"; 2 | import { TabContainer } from "tabcontainer.slint"; 3 | import { TimerConfig } from "timerconfig.slint"; 4 | import { Theme, Settings } from "globals.slint"; 5 | 6 | export component SlideOver { 7 | in-out property expanded; 8 | in-out property logo <=> tc.logo; 9 | 10 | out property tmr-config: tc.tmr-config; 11 | out property cont-x; 12 | 13 | animate cont-x { 14 | duration: 250ms; 15 | easing: ease-in-out; 16 | } 17 | 18 | states [ 19 | vis when root.expanded : { 20 | cont-x: 0px; 21 | } 22 | not-vis when !root.expanded: { 23 | cont-x: self.width * -1; 24 | } 25 | ] 26 | 27 | horizontal-stretch: 0; 28 | Rectangle { 29 | y: 0px; 30 | x: root.cont-x; 31 | height: 100%; 32 | width: 100%; 33 | 34 | i-content := Rectangle { 35 | background: Theme.background-light; 36 | clip: true; 37 | 38 | tc := TabContainer {} 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tomotroid" 3 | version = "0.1.0" 4 | description = "Simple Pomodoro Timer made with Rust + Slint. Design shamelessly ripped from Pomotroid" 5 | edition = "2021" 6 | license = "MIT" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | anyhow = "1.0.97" 12 | open = "5.3" 13 | serde = { version = "1.0", features = ["derive"] } 14 | serde_json = "1.0" 15 | slint = { version = "1.10.0", default-features = false, features = [ "compat-1-2", "std", "accessibility", "backend-winit", "renderer-femtovg", "renderer-software", "serde"] } 16 | i-slint-backend-winit = "=1.10.0" 17 | hex_color = { version = "3.0.0", features = [ "serde" ] } 18 | walkdir = "2.5" 19 | etcetera = "0.10.0" 20 | directories = "6.0" 21 | single-instance = "0.3.3" 22 | global-hotkey = "0.6" 23 | notify-rust = "4.11" 24 | rodio = "0.20" 25 | flexi_logger = "0.30.2" 26 | log = "0.4.27" 27 | 28 | 29 | 30 | [target.'cfg(windows)'.dependencies] 31 | tray-item = "0.10" 32 | eventlog = "0.3.0" 33 | 34 | [target.'cfg(unix)'.dependencies] 35 | tray-item = { version = "0.10", features = [ "ksni" ] } 36 | png = "0.17" 37 | syslog = "7.0.0" 38 | systemd-journal-logger = "2.2.2" 39 | 40 | [build-dependencies] 41 | slint-build = "1.10.0" 42 | 43 | [target.'cfg(windows)'.build-dependencies] 44 | windres = "0.2.2" 45 | #https://github.com/tauri-apps/winrt-notification for notifications on Windows? 46 | 47 | [lints.clippy] 48 | #unwrap_used = "deny" -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | ## A Shameless ripoff of [Pomotroid](https://github.com/Splode/pomotroid). That I am creating to practice my Rust skills and to try and learn [Slint](https://github.com/slint-ui/slint) 2 | 3 | ### Current Status 4 | A lot of the foundation has been laid, but this is not fully functional software yet. 5 | 6 | ## Screenshots 7 | 8 | 9 | 10 | 11 | 12 | 13 | ### Reasons I started this project 14 | - Wanted to practice rust 15 | - Wanted to try a GUI and picked Slint 16 | - There are a handful of things I haven't tried doing before 17 | - Tray Icon 18 | - Cross platform conditional logic (such as handling the tray icons on Windows and Linux) 19 | - Doing some unusual stuff with Slint, like manually re-creating the window frame 20 | - But has a limited enough feature set that I might actually complete the project some day. 21 | 22 | ### Why Mimic Pomotroid? 23 | It seemed like a relativly nice clean design, and I don't claim to be the best UI / UX Designer in the world. Copying someone elses design allows me to focus on learning Slint and practicing Rust instead of struggling to come up with a good design. It also gives me a target to aim for, for example trying to figure out how to animate a [path in slint](https://github.com/slint-ui/slint/discussions/2722) instead of giving up quickly and modifying the design to suite what is easy. -------------------------------------------------------------------------------- /ui/tooltip.slint: -------------------------------------------------------------------------------- 1 | import { Theme } from "globals.slint"; 2 | 3 | export enum TTPosition { 4 | Bottom, 5 | Left, 6 | Right, 7 | Top, 8 | } 9 | 10 | export component ToolTip { 11 | preferred-height: 100%; 12 | preferred-width: 100%; 13 | in-out property show: false; 14 | 15 | in property position: TTPosition.Bottom; 16 | in property owner-height; 17 | in property owner-width; 18 | in property owner-x; 19 | in property owner-y; 20 | in property text; 21 | 22 | x: pos-x(self.width); 23 | y: pos-y(self.height); 24 | z: 1; 25 | width: layout.preferred-width; 26 | height: layout.preferred-height; 27 | 28 | pure public function pos-x(wid: length) -> length { 29 | if (root.position == TTPosition.Right) { 30 | root.owner-x + root.owner-width + 8px; 31 | } else if (root.position == TTPosition.Left) { 32 | root.owner-x - wid - 8px; 33 | } else { 34 | root.owner-x + (root.owner-width - wid) / 2; 35 | } 36 | } 37 | 38 | pure public function pos-y(hght: length) -> length { 39 | if (root.position == TTPosition.Top) { 40 | root.owner-y - 8px - hght; 41 | } else if (root.position == TTPosition.Bottom) { 42 | root.owner-y + root.owner-height + 8px; 43 | } else { 44 | root.owner-y + ((root.owner-height - hght) / 2); 45 | } 46 | } 47 | 48 | tool-tip := Rectangle { 49 | x: 0; 50 | y: 0; 51 | clip: false; 52 | background: black;//Theme.foreground; 53 | border-color: white; 54 | border-width: 1px; 55 | opacity: 0; 56 | visible: show; 57 | width: layout.preferred-width; 58 | height: layout.preferred-height; 59 | layout := HorizontalLayout { 60 | padding: 5px; 61 | Text { 62 | text: root.text; 63 | //text: "Reset"; 64 | color: white;//Theme.background-lightest; 65 | } 66 | } 67 | 68 | states [ 69 | visible when show: { 70 | opacity: 1.0; 71 | in { 72 | animate opacity { 73 | duration: 175ms; delay: 700ms; 74 | } 75 | } 76 | } 77 | ] 78 | } 79 | } -------------------------------------------------------------------------------- /ui/themeconfig.slint: -------------------------------------------------------------------------------- 1 | import { VerticalBox, HorizontalBox, ScrollView } from "std-widgets.slint"; 2 | import {Theme, JsonTheme} from "globals.slint"; 3 | 4 | export global ThemeCallbacks { 5 | pure callback theme-changed(int, JsonTheme); 6 | 7 | in property<[JsonTheme]> themes; 8 | } 9 | 10 | component ThemeBar inherits Rectangle { 11 | in property acc-color: Theme.accent; 12 | in property bg-color: Theme.background; 13 | in property txt-color: Theme.foreground; 14 | in property name: "Pomotroid"; 15 | in-out property selected: false; 16 | callback select; 17 | 18 | width: 80%; 19 | height: 50px; 20 | background: root.bg-color; 21 | border-radius: 5px; 22 | 23 | thm-ta := TouchArea { 24 | clicked => { root.select();} 25 | } 26 | 27 | accent := Rectangle { 28 | x: 0; 29 | height: 100%; 30 | background: root.acc-color; 31 | width: 1%; 32 | } 33 | Text { 34 | x: parent.width * 0.05; 35 | horizontal-alignment: left; 36 | text: root.name; 37 | color: root.txt-color; 38 | font-size: 11pt; 39 | } 40 | Image { 41 | x: parent.width - 10px - self.width; 42 | source: @image-url("../assets/icons/check.svg"); 43 | height: 50%; 44 | visible: parent.selected; 45 | colorize: Theme.accent; 46 | } 47 | } 48 | export component ThemePage inherits Rectangle { 49 | background: Theme.background-light; 50 | in-out property active-theme: Theme.theme-idx; 51 | in property<[JsonTheme]> themes: ThemeCallbacks.themes; 52 | 53 | VerticalBox { 54 | ScrollView { 55 | VerticalBox { 56 | Text { 57 | text: "Themes"; 58 | font-size: 11pt; 59 | horizontal-alignment: center; 60 | } 61 | 62 | for disp-theme[idx] in root.themes : ThemeBar { 63 | acc-color: disp-theme.accent; 64 | bg-color: disp-theme.background; 65 | txt-color: disp-theme.foreground; 66 | name: disp-theme.name; 67 | selected: root.active-theme == idx; 68 | 69 | select => { 70 | ThemeCallbacks.theme-changed(idx, disp-theme); 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /ui/circular-progress.slint: -------------------------------------------------------------------------------- 1 | import { AboutSlint, Button, VerticalBox, Palette, Slider } from "std-widgets.slint"; 2 | 3 | component CircularPath inherits Path { 4 | in property progress: 100; 5 | in property fg_color: green; 6 | in property inner-radius: 45; 7 | in property start : 0; 8 | 9 | private property progressClamped: clamp(progress, 0, 0.9999); 10 | 11 | fill-rule: FillRule.nonzero; 12 | viewbox-width: 100; 13 | viewbox-height: 100; 14 | stroke-width: 0px; 15 | fill: fg_color; 16 | 17 | MoveTo { 18 | y: 50 - 50 * cos(-root.start * 360deg); 19 | x: 50 - 50 * sin(-root.start * 360deg); 20 | } 21 | 22 | ArcTo { 23 | y: 50 - root.inner-radius * cos(-root.start * 360deg); 24 | x: 50 - root.inner-radius * sin(-root.start * 360deg); 25 | radius-x: 1; 26 | radius-y: 1; 27 | } 28 | 29 | ArcTo { 30 | radius-x: root.inner-radius; 31 | radius-y: root.inner-radius; 32 | y: 50 - root.inner-radius*cos(-(root.start + root.progressClamped) * 360deg); 33 | x: 50 - root.inner-radius*sin(-(root.start + root.progressClamped) * 360deg); 34 | sweep: root.progressClamped > 0; 35 | large-arc: root.progressClamped > 0.5; 36 | } 37 | 38 | ArcTo { 39 | y: 50 - 50*cos(-(root.start + root.progressClamped) * 360deg); 40 | x: 50 - 50*sin(-(root.start + root.progressClamped) * 360deg); 41 | radius-x: 1; 42 | radius-y: 1; 43 | } 44 | 45 | ArcTo { 46 | radius-x: 50; 47 | radius-y: 50; 48 | y: 50 - 50 * cos(-root.start * 360deg); 49 | x: 50 - 50 * sin(-root.start * 360deg); 50 | sweep: root.progressClamped < 0; 51 | large-arc: root.progressClamped > 0.5; 52 | } 53 | } 54 | 55 | export component CircularProgress { 56 | in property progress <=> cp.progress; 57 | in property bg_color; 58 | in property fg_color <=> cp.fg_color; 59 | in property txt_color; 60 | in property prog_text; 61 | in property lbl_text; 62 | 63 | Rectangle { 64 | Rectangle { 65 | border-color: bg_color; 66 | border-radius: self.height/2; 67 | width: cp.width * 0.965; 68 | height: cp.height * 0.965; 69 | border-width: 3px; 70 | } 71 | cp := CircularPath { 72 | width: 100%; 73 | height: 100%; 74 | inner-radius: 45; 75 | start: 0; 76 | stroke-width: 0px; 77 | fg_color: red; 78 | 79 | animate progress { 80 | duration: 1s; 81 | } 82 | } 83 | Timer := Text { 84 | font-family: "Roboto Mono"; 85 | text: prog_text; 86 | color: txt_color; 87 | font-size: 46px; 88 | } 89 | task-label := Text { 90 | text: lbl_text; 91 | font-family: "Lato"; 92 | y: parent.height * 0.70; 93 | color: txt_color; 94 | font-size: 12pt; 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /ui/about.slint: -------------------------------------------------------------------------------- 1 | import { VerticalBox, HorizontalBox } from "std-widgets.slint"; 2 | import { Theme } from "globals.slint"; 3 | import { HyperLink } from "hyperlink.slint"; 4 | 5 | export component AboutPage inherits Rectangle { 6 | //my initial attempts to add the logo to the theme weren't working. Passing it all the way through 7 | //from multiple components feels a bit hacky, but it was a quick way to test the rest of my logic 8 | //in regartds to updating the SVG and re-applying the logo 9 | in-out property logo <=> hl-logo.source; 10 | background: Theme.background-light; 11 | VerticalBox { 12 | spacing: 5px; 13 | 14 | Text { 15 | text: "About"; 16 | font-size: 11pt; 17 | //font-weight: 500; 18 | horizontal-alignment: center; 19 | } 20 | HorizontalLayout { 21 | alignment: center; 22 | hl-logo := Image { 23 | source: @image-url("../assets/logo.svg"); 24 | height: 75px; 25 | width: 75px; 26 | } 27 | } 28 | 29 | Text { 30 | text: "Tomotroid"; 31 | font-size: 16pt; 32 | color: Theme.accent; 33 | horizontal-alignment: center; 34 | } 35 | 36 | HorizontalBox { 37 | alignment: center; 38 | spacing: 3pt; 39 | Text { 40 | text: "A themeable pomodoro timer created to learn"; 41 | font-size: 11pt; 42 | color: Theme.background-lightest; 43 | } 44 | HyperLink { 45 | text: "Slint"; 46 | url: "https://slint.dev"; 47 | link-color: Theme.background-lightest; 48 | hvr-color: Theme.accent; 49 | font-size: 11pt; 50 | } 51 | } 52 | 53 | HorizontalBox { 54 | alignment: center; 55 | spacing: 3pt; 56 | Text { 57 | text: "Design modeled after"; 58 | font-size: 11pt; 59 | color: Theme.background-lightest; 60 | padding: 0px; 61 | } 62 | HyperLink { 63 | text: "Pomotroid."; 64 | url: "https://splode.github.io/pomotroid/"; 65 | link-color: Theme.background-lightest; 66 | hvr-color: Theme.accent; 67 | font-size: 11pt; 68 | padding: 0px; 69 | } 70 | } 71 | 72 | HorizontalBox { 73 | alignment: center; 74 | Text { 75 | text: "Version 0.1.0"; 76 | font-size: 11pt; 77 | color: Theme.background-lightest; 78 | } 79 | HyperLink { 80 | text: "(release notes)"; 81 | url: "Get a link for release notes"; 82 | link-color: Theme.background-lightest; 83 | hvr-color: Theme.accent; 84 | font-size: 11pt; 85 | } 86 | } 87 | HyperLink { 88 | text: "License and Documentation"; 89 | url: "https://github.com/Vadoola/Tomotroid/blob/main/LICENSE"; 90 | link-color: Theme.background-lightest; 91 | hvr-color: Theme.accent; 92 | font-size: 11pt; 93 | horizontal-alignment: center; 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /ui/globals.slint: -------------------------------------------------------------------------------- 1 | //default color theme is "Pomotroid" from Pomotroid software I'm mimicking. 2 | export global Theme { 3 | in-out property theme-idx: 0; 4 | in-out property long-round: #af486d; 5 | in-out property short-round: #719002; 6 | in-out property focus-round: #3c73b8; 7 | in-out property background: #1a191e; 8 | in-out property background-light: #343132; 9 | in-out property background-lightest: #837c7e; 10 | in-out property foreground: #dfdfd7; 11 | in-out property foreground-darker: #bec0c0; 12 | in-out property foreground-darkest: #adadae; 13 | in-out property accent: #cd7a0c; 14 | in-out property letter-spacing: 3px; 15 | in-out property logo: @image-url("../assets/logo.svg"); 16 | } 17 | 18 | //@rust-attr(derive(serde::Deserialize)) 19 | export struct JsonTheme { 20 | name: string, 21 | long-round: brush, 22 | short-round: brush, 23 | focus-round: brush, 24 | background: brush, 25 | background-light: brush, 26 | background-lightest: brush, 27 | foreground: brush, 28 | foreground-darker: brush, 29 | foreground-darkest: brush, 30 | accent: brush, 31 | } 32 | 33 | export enum BoolSettTypes { 34 | AlwOnTop, 35 | BrkAlwOnTop, 36 | AutoStrtWrkTim, 37 | AutoStrtBreakTim, 38 | TickSounds, 39 | TickSoundsBreak, 40 | Notifications, 41 | MinToTray, 42 | MinToTryCls, 43 | } 44 | 45 | export enum IntSettTypes { 46 | LongBreak, 47 | ShortBreak, 48 | Work, 49 | Volume, 50 | Rounds, 51 | } 52 | 53 | //@rust-attr(derive(serde::Deserialize)) 54 | export struct ConfigData { 55 | name: string, 56 | state: bool, 57 | sett-param: BoolSettTypes, 58 | enabled: bool, 59 | animate-in: bool, 60 | animate-out: bool, 61 | } 62 | 63 | export global Settings { 64 | in-out property always-on-top; 65 | in-out property auto-start-break-timer; 66 | in-out property auto-start-work-timer; 67 | in-out property break-always-on-top; 68 | 69 | //in-out property<...shortcuts...> global-shortcuts; 70 | //I'm thinking instead of strings, I need to set this to a struct 71 | //that can match the json variant I seralize/deserialize 72 | //then have a slint function to convert it to a string format? 73 | //or would I need to store both properties here? A string version 74 | //generated in rust and the struct version? 75 | in-out property tt_ghk; 76 | in-out property rst_ghk; 77 | in-out property skp_ghk; 78 | 79 | in-out property min-to-tray; 80 | in-out property min-to-tray-on-close; 81 | in-out property notifications; 82 | in-out property theme; 83 | in-out property tick-sounds; 84 | in-out property tick-sounds-during-break; 85 | in-out property time-long-break; 86 | in-out property time-short-break; 87 | in-out property time-work; 88 | in-out property volume; 89 | in-out property work-rounds; 90 | 91 | in property is-wayland; 92 | 93 | //hmm so maybe an enum with each setting in it, and can pass the enum to the callback? 94 | //but I guess I would a callback for each type, ie a bool-changed, int-changed, etc? 95 | //Is there a cleaner way? I'm trying to avoid one call back for each setting 96 | //pure callback setting-changed(SettingVal, enum of Settings?) 97 | 98 | callback bool-changed(BoolSettTypes, bool); 99 | callback int-changed(IntSettTypes, int); 100 | } 101 | -------------------------------------------------------------------------------- /src/setup.rs: -------------------------------------------------------------------------------- 1 | #[cfg(unix)] 2 | use anyhow::Result; 3 | use log::{error, LevelFilter}; 4 | use std::{ 5 | io::Cursor, 6 | sync::mpsc::{self, Receiver}, 7 | }; 8 | use systemd_journal_logger::{connected_to_journal, JournalLog}; 9 | use tray_item::{IconSource, TrayItem}; 10 | 11 | pub enum TrayMsg { 12 | MinRes, 13 | Quit, 14 | } 15 | 16 | pub fn logging() { 17 | #[cfg(unix)] //is this true for Mac? how would I test for non-mac *nix? 18 | let fallback_needed = if connected_to_journal() { 19 | //if systemd journal is found use it 20 | JournalLog::new() 21 | .unwrap() 22 | .with_extra_fields(vec![("VERSION", env!("CARGO_PKG_VERSION"))]) 23 | .with_syslog_identifier("Tomotroid".to_string()) 24 | .install() 25 | .unwrap(); 26 | false 27 | } else { 28 | //check for syslog, and then fall back to text file 29 | //My dev machine here DOES have a systemd journal....is there an easy way to disable that 30 | //and test syslog? Never tried, or do I need to spin up a different VM? 31 | true 32 | }; 33 | 34 | /*#[cfg(windows)] 35 | let fallback_needed = if ?? { 36 | //try to setup windows event logging 37 | }*/ 38 | 39 | /*#[cfg(mac)]...not sure the right command here yet, need to look 40 | let fallback_needed = true 41 | */ 42 | 43 | if fallback_needed { 44 | //setup text file logging if: 45 | // * nix systems: systemd journal or syslog fails 46 | // * Windows: Event log fails 47 | // * Mac: always currentlys 48 | } 49 | 50 | //default to warn until we can read from settings or ENV 51 | log::set_max_level(LevelFilter::Warn); 52 | } 53 | 54 | //TODO: I'm not seeing an obvious way to mimic the Pomotroid behavoir 55 | //where it just minimizes or restores by clicking the tray icon 56 | //because I don't see any way to capture when the tray icon is clicked 57 | //I'll need to dig into this more. For now I'll just add some menu items 58 | //to get some basic functionality and test minimzing to the tray etc 59 | pub fn tray() -> Result> { 60 | let mut tray = create_tray()?; 61 | 62 | let (tray_tx, tray_rx) = mpsc::sync_channel(1); 63 | 64 | let minres_tx = tray_tx.clone(); 65 | tray.add_menu_item("Minimize / Restore", move || { 66 | minres_tx.send(TrayMsg::MinRes).unwrap(); 67 | })?; 68 | 69 | let quit_tx = tray_tx; 70 | tray.add_menu_item("Quit", move || { 71 | quit_tx.send(TrayMsg::Quit).unwrap(); 72 | })?; 73 | 74 | Ok(tray_rx) 75 | } 76 | 77 | #[cfg(unix)] 78 | fn create_tray() -> Result { 79 | let logo_cursor = Cursor::new(include_bytes!("../assets/icons/logo.png")); 80 | let logo_decoder = png::Decoder::new(logo_cursor); 81 | let mut logo_reader = logo_decoder.read_info().unwrap(); 82 | let mut logo_buff = vec![0; logo_reader.output_buffer_size()]; 83 | 84 | logo_reader.next_frame(&mut logo_buff).map_err(|de| { 85 | error!("Unable to decode tray icon file: {de}"); 86 | de 87 | })?; 88 | 89 | let logo_icon = IconSource::Data { 90 | data: logo_buff, 91 | height: 32, 92 | width: 32, 93 | }; 94 | 95 | TrayItem::new("Tomotroid\nClick to Restore", logo_icon).map_err(|e| { 96 | error!("Error generating the System Tray"); 97 | e.into() 98 | }) 99 | } 100 | 101 | #[cfg(windows)] 102 | fn create_tray() -> Result { 103 | TrayItem::new( 104 | "Tomotroid\nClick to Restore", 105 | IconSource::Resource("logo-icon"), 106 | ) 107 | } 108 | 109 | pub fn backend() { 110 | let backend = { 111 | #[cfg(target_os = "macos")] 112 | { 113 | use i_slint_backend_winit::winit::platform::macos::WindowBuilderExtMacOS; 114 | 115 | let mut backend = i_slint_backend_winit::Backend::new().unwrap(); 116 | backend.window_builder_hook = Some(Box::new(|builder| builder.with_decorations(false))); 117 | backend 118 | } 119 | 120 | #[cfg(not(target_os = "macos"))] 121 | i_slint_backend_winit::Backend::new().unwrap() 122 | }; 123 | 124 | slint::platform::set_platform(Box::new(backend)).unwrap(); 125 | } 126 | -------------------------------------------------------------------------------- /ui/slider.slint: -------------------------------------------------------------------------------- 1 | import {Theme} from "globals.slint"; 2 | export component Slider { 3 | callback value-changed(int); 4 | 5 | in-out property value: 0; 6 | 7 | in property minimum: 0; 8 | in property maximum: 100; 9 | in property step: 1; 10 | in property color: red; 11 | in property mt-color: black; 12 | in property ft-color: red; 13 | in property ghv-color: red; 14 | in property vertical: false; 15 | 16 | min-width: 16px; 17 | min-height: grabber.height; 18 | horizontal-stretch: root.vertical ? 0 : 1; 19 | vertical-stretch: root.vertical ? 1 :0; 20 | 21 | main-track := Rectangle { 22 | y: (parent.height - self.height) / 2; 23 | background: root.mt-color; 24 | 25 | animate background { duration: 150ms; } 26 | } 27 | 28 | filled-track := Rectangle { 29 | background: parent.color; 30 | 31 | animate background { duration: 150ms; } 32 | } 33 | 34 | grabber := Rectangle { 35 | width: 18px; 36 | height: self.width; 37 | border-radius: self.width / 2; 38 | background: slider-ta.has-hover ? parent.ghv-color : parent.color; 39 | animate background { duration: 150ms; } 40 | } 41 | 42 | slider-ta := TouchArea { 43 | property pressed-value; 44 | property minimum: parent.minimum; 45 | property maximum: parent.maximum; 46 | property step-size: (self.maximum - self.minimum) / parent.step; 47 | 48 | width: parent.width; 49 | height: parent.height; 50 | 51 | pointer-event(event) => { 52 | if(event.button == PointerEventButton.left && event.kind == PointerEventKind.down) { 53 | self.pressed-value = root.value; 54 | } 55 | } 56 | 57 | moved => { 58 | if(self.enabled && self.pressed) { 59 | if (root.vertical) { 60 | root.value = max(slider-ta.minimum, min(slider-ta.maximum, 61 | self.pressed-value - (slider-ta.mouse-y - slider-ta.pressed-y) * (self.maximum - self.minimum) / (root.height - grabber.height))); 62 | } else { 63 | root.value = max(slider-ta.minimum, min(slider-ta.maximum, 64 | self.pressed-value + (slider-ta.mouse-x - slider-ta.pressed-x) * (self.maximum - self.minimum) / (root.width - grabber.width))); 65 | } 66 | root.value-changed(root.value); 67 | } 68 | } 69 | } 70 | 71 | i-focus-scope := FocusScope { 72 | x: 0px; 73 | width: 0px; 74 | 75 | key-pressed(event) => { 76 | if(self.enabled && event.text == Key.RightArrow) { 77 | root.value = Math.min(root.value + slider-ta.step-size, slider-ta.maximum); 78 | root.value-changed(root.value); 79 | accept 80 | } else if(self.enabled && event.text == Key.LeftArrow) { 81 | root.value = Math.max(root.value - slider-ta.step-size, slider-ta.minimum); 82 | root.value-changed(root.value); 83 | accept 84 | } else { 85 | reject 86 | } 87 | } 88 | } 89 | 90 | states [ 91 | vert when root.vertical : { 92 | main-track.width: 3px; 93 | main-track.height: self.height; 94 | 95 | filled-track.x: main-track.x; 96 | filled-track.y: self.height - filled-track.height; 97 | filled-track.height: self.height * ((self.value - (self.minimum/2))/self.maximum); 98 | filled-track.width: main-track.width; 99 | 100 | grabber.x: (self.width - grabber.width) / 2; 101 | grabber.y: (self.height - grabber.height) - ((self.height - grabber.height) * (self.value - slider-ta.minimum) / (slider-ta.maximum - slider-ta.minimum)); 102 | } 103 | 104 | horiz when !root.vertical : { 105 | main-track.width: self.width; 106 | main-track.height: 3px; 107 | 108 | filled-track.x: 0; 109 | filled-track.y: (self.height - filled-track.height) / 2; 110 | filled-track.height: main-track.height; 111 | filled-track.width: self.width * ((self.value - (self.minimum/2))/self.maximum); 112 | 113 | grabber.x: (self.width - grabber.width) * (self.value - slider-ta.minimum) / (slider-ta.maximum - slider-ta.minimum); 114 | grabber.y: (self.height - grabber.height) / 2; 115 | } 116 | ] 117 | } -------------------------------------------------------------------------------- /ui/timerconfig.slint: -------------------------------------------------------------------------------- 1 | import "../assets/fonts/RobotoMono-Light.ttf"; 2 | 3 | import { VerticalBox, HorizontalBox } from "std-widgets.slint"; 4 | import { Slider } from "slider.slint"; 5 | import { Theme, Settings, IntSettTypes } from "globals.slint"; 6 | 7 | export struct TimerConfig { 8 | focus-time: duration, 9 | shbrk-time: duration, 10 | lgbrk-time: duration, 11 | rounds: int, 12 | } 13 | 14 | component ValueTag inherits HorizontalLayout { 15 | in property label; 16 | 17 | alignment: center; 18 | Rectangle { 19 | height: 20px; 20 | width: lbl.width + 10px; 21 | border-radius: 5px; 22 | background: Theme.background; 23 | 24 | lbl := Text { 25 | text: root.label; 26 | font-family: "Roboto Mono"; 27 | font-weight: 800; 28 | color: Theme.foreground; 29 | } 30 | } 31 | } 32 | 33 | component TimerSlider inherits VerticalLayout { 34 | callback value-changed(int); 35 | 36 | in property label; 37 | in property min; 38 | in property max; 39 | in property tail; 40 | in property sl-color; 41 | 42 | in-out property value <=> sldr.value; 43 | 44 | alignment: end; 45 | 46 | 47 | Text { 48 | text: root.label; 49 | horizontal-alignment: center; 50 | font-size: 11pt; 51 | color: Theme.foreground-darker; 52 | } 53 | ValueTag { 54 | label: "\{sldr.value}\{root.tail}"; 55 | } 56 | Rectangle { 57 | sldr := Slider { 58 | width: parent.width; 59 | height: 20px; 60 | value: 1; 61 | minimum: root.min; 62 | maximum: root.max; 63 | color: root.sl-color; 64 | ft-color: root.sl-color; 65 | ghv-color: root.sl-color; 66 | mt-color: Theme.background; 67 | value-changed(int) => { 68 | root.value-changed(int) 69 | } 70 | } 71 | } 72 | } 73 | 74 | export component TimerConfigPage inherits Rectangle { 75 | in-out property config: { 76 | focus-time: focus-slider.value * 60s, 77 | shbrk-time: shbrk-slider.value * 60s, 78 | lgbrk-time: lngbrk-slider.value * 60s, 79 | rounds: round-slider.value, 80 | }; 81 | 82 | background: Theme.background-light; 83 | VerticalBox { 84 | Text { 85 | text: "Timer"; 86 | horizontal-alignment: center; 87 | font-size: 11pt; 88 | color: Theme.foreground; 89 | } 90 | focus-slider := TimerSlider { 91 | label: "Focus"; 92 | min: 1; 93 | max: 90; 94 | value <=> Settings.time-work; 95 | tail: ":00"; 96 | sl-color: Theme.focus-round; 97 | value-changed(int) => { 98 | Settings.int-changed(IntSettTypes.Work, int); 99 | } 100 | } 101 | shbrk-slider := TimerSlider { 102 | label: "Short Break"; 103 | min: 1; 104 | max: 90; 105 | value <=> Settings.time-short-break; 106 | tail: ":00"; 107 | sl-color: Theme.short-round; 108 | value-changed(int) => { 109 | Settings.int-changed(IntSettTypes.ShortBreak, int); 110 | } 111 | } 112 | lngbrk-slider := TimerSlider { 113 | label: "Long Break"; 114 | min: 1; 115 | max: 90; 116 | value <=> Settings.time-long-break; 117 | tail: ":00"; 118 | sl-color: Theme.long-round; 119 | value-changed(int) => { 120 | Settings.int-changed(IntSettTypes.LongBreak, int); 121 | } 122 | } 123 | round-slider := TimerSlider { 124 | label: "Round"; 125 | min: 1; 126 | max: 12; 127 | value <=> Settings.work-rounds; 128 | sl-color: Theme.background-lightest; 129 | value-changed(int) => { 130 | Settings.int-changed(IntSettTypes.Rounds, int); 131 | } 132 | } 133 | Text { 134 | text: "Reset Defaults"; 135 | font-size: 11pt; 136 | horizontal-alignment: center; 137 | rd-ta := TouchArea { 138 | clicked => { 139 | Settings.int-changed(IntSettTypes.Work, 25); 140 | Settings.int-changed(IntSettTypes.ShortBreak, 5); 141 | Settings.int-changed(IntSettTypes.LongBreak, 15); 142 | Settings.int-changed(IntSettTypes.Rounds, 4); 143 | } 144 | } 145 | 146 | states [ 147 | on-hvr when rd-ta.has-hover: { 148 | color: Theme.accent; 149 | } 150 | off-hvr when !rd-ta.has-hover: { 151 | color: Theme.background-lightest; 152 | } 153 | ] 154 | } 155 | } 156 | } -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 34 | 39 | 45 | 51 | 57 | 64 | 71 | 78 | 85 | 92 | 99 | 106 | 107 | 109 | 110 | 112 | 114 | 115 | 117 | 119 | 121 | 123 | 125 | 127 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /ui/tabcontainer.slint: -------------------------------------------------------------------------------- 1 | import { Theme } from "globals.slint"; 2 | import { TimerConfig, TimerConfigPage } from "timerconfig.slint"; 3 | import { AboutPage } from "about.slint"; 4 | import { ThemePage } from "themeconfig.slint"; 5 | import { ConfigPage } from "config.slint"; 6 | import { ToolTip, TTPosition } from "tooltip.slint"; 7 | 8 | component BottomBarIcon inherits Rectangle { 9 | in-out property active; 10 | out property hover: bbi-ta.has-hover; 11 | callback activate; 12 | 13 | Rectangle { 14 | padding: 0px; 15 | slct := Rectangle { 16 | height: 1px; 17 | width: parent.width; 18 | y: parent.height - 1px; 19 | background: Theme.accent; 20 | 21 | animate width, x { 22 | duration: 150ms; 23 | } 24 | } 25 | @children 26 | } 27 | 28 | bbi-ta := TouchArea { 29 | clicked => { 30 | root.activate(); 31 | } 32 | } 33 | 34 | animate background { 35 | duration: 150ms; 36 | } 37 | 38 | states [ 39 | hvr-act when bbi-ta.has-hover && root.active: { 40 | background: Theme.background.brighter(0.3); 41 | slct.width: self.width * 0.333; 42 | slct.x: 0px + (self.width * 0.333); 43 | } 44 | hvr-nt-act when bbi-ta.has-hover && !root.active: { 45 | background: Theme.background.brighter(0.3); 46 | slct.width: 0px; 47 | slct.x: self.width / 2; 48 | } 49 | nt-hvr-act when !bbi-ta.has-hover && root.active: { 50 | background: Theme.background; 51 | slct.width: self.width * 0.333; 52 | slct.x: 0px + (self.width * 0.333); 53 | } 54 | nt-hvr-nt-act when !bbi-ta.has-hover && !root.active: { 55 | background: Theme.background; 56 | slct.width: 0px; 57 | slct.x: self.width / 2; 58 | } 59 | ] 60 | } 61 | 62 | component BottomBar inherits Rectangle { 63 | width: 100%; 64 | height: 35px; 65 | background: Theme.background; 66 | out property active-page: 0; 67 | //out property hovered-tab: 0; 68 | 69 | Rectangle { 70 | for btn-info[idx] in [ 71 | { page-icon: @image-url("../assets/icons/clock.svg"), tooltip: "Timer Configuraiton" }, 72 | { page-icon: @image-url("../assets/icons/gear.svg"), tooltip: "Options" }, 73 | { page-icon: @image-url("../assets/icons/pallette.svg"), tooltip: "Themes" }, 74 | { page-icon: @image-url("../assets/icons/info.svg"), tooltip: "About" }, 75 | ] : BottomBarIcon { 76 | y: 0px; 77 | x: self.width * idx; 78 | height: 35px; 79 | width: parent.width / 4; 80 | active: root.active-page == idx; 81 | 82 | icon := Image { 83 | colorize: Theme.background-lightest; 84 | animate colorize { 85 | duration: 125ms; 86 | } 87 | source: btn-info.page-icon; 88 | image-fit: contain; 89 | width: 60%; 90 | height: 60%; 91 | } 92 | 93 | activate => { 94 | root.active-page = idx; 95 | } 96 | } 97 | } 98 | } 99 | 100 | export component TabContainer inherits VerticalLayout { 101 | //my initial attempts to add the logo to the theme weren't working. Passing it all the way through 102 | //from multiple components feels a bit hacky, but it was a quick way to test the rest of my logic 103 | //in regartds to updating the SVG and re-applying the logo 104 | in-out property logo <=> info.logo; 105 | 106 | out property tmr-config: clock.config; 107 | 108 | Rectangle { 109 | clock := TimerConfigPage { 110 | animate opacity, x { 111 | duration: 150ms; 112 | easing: ease-in-out; 113 | } 114 | visible: self.opacity > 0%; 115 | } 116 | gear := ConfigPage { 117 | animate opacity, x { 118 | duration: 150ms; 119 | easing: ease-in-out; 120 | } 121 | visible: self.opacity > 0%; 122 | } 123 | pall := ThemePage { 124 | animate opacity, x { 125 | duration: 150ms; 126 | easing: ease-in-out; 127 | } 128 | visible: self.opacity > 0%; 129 | } 130 | info := AboutPage { 131 | animate opacity, x { 132 | duration: 150ms; 133 | easing: ease-in-out; 134 | } 135 | visible: self.opacity > 0%; 136 | } 137 | 138 | states [ 139 | tab0 when bb.active-page == 0 : { 140 | clock.opacity: 100%; 141 | gear.opacity: 0%; 142 | pall.opacity: 0%; 143 | info.opacity: 0%; 144 | } 145 | tab1 when bb.active-page == 1 : { 146 | clock.opacity: 0%; 147 | gear.opacity: 100%; 148 | pall.opacity: 0%; 149 | info.opacity: 0%; 150 | } 151 | tab2 when bb.active-page == 2 : { 152 | clock.opacity: 0%; 153 | gear.opacity: 0%; 154 | pall.opacity: 100%; 155 | info.opacity: 0%; 156 | } 157 | tab3 when bb.active-page == 3 : { 158 | clock.opacity: 0%; 159 | gear.opacity: 0%; 160 | pall.opacity: 0%; 161 | info.opacity: 100%; 162 | } 163 | ] 164 | } 165 | bb := BottomBar {} 166 | //hmm ideally I want the tooltips here on the Tabcontainer....but I need to get the owner info to show them...how to buble this info up? 167 | 168 | 169 | //not sure this is the best way to do it...I feel like I'm duplicating the logic here a bit, but since I can't 170 | //access the individual elements of the BottomBarIcon's outside of the for loop, I'm not sure how else to do this 171 | //actually how do I get the hover state to bubble up properly.... 172 | /*for btn-info[idx] in [ 173 | { page-icon: @image-url("../assets/icons/clock.svg"), tooltip: "Timer Configuraiton" }, 174 | { page-icon: @image-url("../assets/icons/gear.svg"), tooltip: "Options" }, 175 | { page-icon: @image-url("../assets/icons/pallette.svg"), tooltip: "Themes" }, 176 | { page-icon: @image-url("../assets/icons/info.svg"), tooltip: "About" }, 177 | ] : ToolTip { 178 | x: BottomBar.absolute-position.x + (BottomBar.width / 4 * idx); 179 | y: BottomBar.absolute-position.y; 180 | owner-height: 35px; 181 | owner-width: BottomBar.width / 4; 182 | position: TTPosition.Top; 183 | show: ??; 184 | text: btn-info.tooltip; 185 | }*/ 186 | } -------------------------------------------------------------------------------- /ui/borderless-window.slint: -------------------------------------------------------------------------------- 1 | import "../assets/fonts/RobotoMono-Light.ttf"; 2 | import "../assets/fonts/Lato-Regular.ttf"; 3 | import {Theme} from "globals.slint"; 4 | import { ToolTip, TTPosition } from "tooltip.slint"; 5 | 6 | 7 | export component BorderlessWindow inherits Window { 8 | no-frame: true; 9 | default-font-family: "Lato"; 10 | min-width: 200px; 11 | min-height: 200px; 12 | 13 | in property parent; 14 | out property menu-open <=> menu-btn.toggled; 15 | 16 | callback close(); 17 | callback minimize(); 18 | callback move(); 19 | callback menu-toggled(); 20 | 21 | VerticalLayout { 22 | padding: 0; 23 | 24 | // Title Bar 25 | Rectangle { 26 | height: 38px; 27 | background: Theme.background; 28 | HorizontalLayout { 29 | padding-right: 7px; 30 | padding-left: 7px; 31 | 32 | //Below is the Menu button that opens the sliding popover. 33 | //And has the animation to conver the menu bars to a back arrow 34 | menu-btn := Rectangle { 35 | property toggled: false; 36 | 37 | property mt-1-x: menu-btn.width * 0.2/1px; 38 | property mt-1-y: menu-btn.height * 0.5/1px; 39 | 40 | property lt-1-x: menu-btn.width * 0.2/1px; 41 | property lt-1-y: menu-btn.height * 0.15/1px; 42 | 43 | property mt-2-x: menu-btn.width * 0.2/1px; 44 | property mt-2-y: menu-btn.height * 0.5/1px; 45 | 46 | property lt-2-x: menu-btn.width * 0.60/1px; 47 | property lt-2-y: menu-btn.height * 0.85/1px; 48 | 49 | animate mt-1-x, mt-1-y, lt-1-x, lt-1-y, mt-2-x, mt-2-y, lt-2-x, lt-2-y { 50 | duration: 200ms; 51 | easing: ease-in-out; 52 | } 53 | 54 | y: parent.height/2 - self.height/2; 55 | width: parent.height * 0.75; 56 | height: parent.height * 0.75; 57 | 58 | menuBtn-ta := TouchArea { 59 | clicked => { 60 | toggled = !toggled; 61 | root.menu-toggled(); 62 | } 63 | } 64 | Path { 65 | stroke: menuBtn-ta.has-hover? closeBtn-ta.pressed? Theme.background-lightest: Theme.accent : Theme.background-lightest; 66 | stroke-width: 2px; 67 | viewbox-height: parent.height/1px; 68 | viewbox-width: parent.width/1px; 69 | 70 | MoveTo { 71 | x: mt-1-x; 72 | y: mt-1-y; 73 | } 74 | 75 | LineTo { 76 | x: lt-1-x; 77 | y: lt-1-y; 78 | } 79 | 80 | MoveTo { 81 | x: mt-2-x; 82 | y: mt-2-y; 83 | } 84 | 85 | LineTo { 86 | x: lt-2-x; 87 | y: lt-2-y; 88 | } 89 | } 90 | 91 | states [ 92 | open when self.toggled: { 93 | mt-1-x: menu-btn.width * 0.2/1px;//top left 94 | mt-1-y: menu-btn.height * 0.5/1px; 95 | 96 | lt-1-x: menu-btn.width * 0.60/1px;//top right 97 | lt-1-y: menu-btn.height * 0.15/1px; 98 | 99 | mt-2-x: menu-btn.width * 0.2/1px;//bottom left 100 | mt-2-y: menu-btn.height * 0.5/1px; 101 | 102 | lt-2-x: menu-btn.width * 0.6/1px;//bottom right 103 | lt-2-y: menu-btn.height * 0.85/1px; 104 | } 105 | 106 | closed when !self.toggled: { 107 | mt-1-x: menu-btn.width * 0.1/1px; 108 | mt-1-y: menu-btn.height * 0.333/1px; 109 | 110 | lt-1-x: menu-btn.width * 0.90/1px; 111 | lt-1-y: menu-btn.height * 0.333/1px; 112 | 113 | mt-2-x: menu-btn.width * 0.1/1px; 114 | mt-2-y: menu-btn.height * 0.667/1px; 115 | 116 | lt-2-x: menu-btn.width * 0.50/1px; 117 | lt-2-y: menu-btn.height * 0.667/1px; 118 | } 119 | ] 120 | } 121 | //this below rectangle is just to create an even amount of objects 122 | //in the horizontal layout so the title text ends up in the middle 123 | //this is a bit hacky, but a quick way to get it centered while I 124 | //work on other stuff...need to come back to this. 125 | Rectangle { 126 | y: parent.height/2 - self.height/2; 127 | visible: false; 128 | width: parent.height * 0.75; 129 | height: parent.height * 0.75; 130 | background: red; 131 | } 132 | 133 | Text { 134 | text: root.title; 135 | font-size: 13pt; 136 | color: Theme.short-round; 137 | vertical-alignment: center; 138 | horizontal-alignment: center; 139 | TouchArea { 140 | moved => { 141 | if (self.pressed && self.enabled) { 142 | root.move(); 143 | } 144 | } 145 | } 146 | } 147 | 148 | Rectangle { 149 | y: parent.height/2 - self.height/2; 150 | width: parent.height * 0.75; 151 | height: parent.height * 0.75; 152 | 153 | minBtn-ta := TouchArea { 154 | clicked => { root.minimize() } 155 | } 156 | Image { 157 | source: @image-url("../assets/icons/minimize.svg"); 158 | animate colorize { 159 | duration: 250ms; 160 | easing: ease-in-out; 161 | } 162 | 163 | states [ 164 | hvr when minBtn-ta.has-hover : { 165 | colorize: Theme.accent; 166 | } 167 | 168 | nthvr when !minBtn-ta.has-hover : { 169 | colorize: Theme.background-lightest; 170 | } 171 | ] 172 | } 173 | 174 | } 175 | 176 | Rectangle { 177 | y: parent.height/2 - self.height/2; 178 | width: parent.height * 0.75; 179 | height: parent.height * 0.75; 180 | closeBtn-ta := TouchArea { 181 | clicked => { root.close() } 182 | } 183 | Image { 184 | source: @image-url("../assets/icons/close.svg"); 185 | colorize: closeBtn-ta.has-hover? closeBtn-ta.pressed? Theme.background-light: Theme.focus-round : Theme.background-lightest; 186 | animate colorize { 187 | duration: 250ms; 188 | } 189 | } 190 | } 191 | } 192 | } 193 | Rectangle { 194 | background: Theme.background; 195 | @children 196 | } 197 | } 198 | 199 | settings-tt := ToolTip { 200 | owner-x: menuBtn-ta.absolute-position.x; 201 | owner-y: menuBtn-ta.absolute-position.y; 202 | owner-height: menuBtn-ta.height; 203 | owner-width: menuBtn-ta.width; 204 | position: TTPosition.Right; 205 | show: menuBtn-ta.has-hover; 206 | text: "Settings"; 207 | } 208 | } -------------------------------------------------------------------------------- /ui/config.slint: -------------------------------------------------------------------------------- 1 | import { Theme, Settings, BoolSettTypes, ConfigData } from "globals.slint"; 2 | import { VerticalBox, HorizontalBox, ScrollView, TextEdit } from "std-widgets.slint"; 3 | import { ToolTip } from "tooltip.slint"; 4 | 5 | enum GHKShortcuts { 6 | toggle-timer, 7 | reset-timer, 8 | skip-round, 9 | } 10 | 11 | export global ConfigCallbacks { 12 | pure callback new-ghk(GHKShortcuts, KeyEvent); 13 | 14 | in property<[ConfigData]> configs; 15 | } 16 | 17 | export component CheckBox { 18 | callback clicked; 19 | in-out property checked; 20 | in property enabled: true; 21 | 22 | width: self.height; 23 | 24 | checkbox := Rectangle { 25 | width: 18px; 26 | height: 18px; 27 | border-color: Theme.background-lightest; 28 | border-width: 2px; 29 | border-radius: self.width * 50%; 30 | 31 | animate background { duration: 150ms; } 32 | } 33 | 34 | ta := TouchArea { 35 | mouse-cursor: parent.enabled ? default : not-allowed; 36 | clicked => { 37 | if (parent.enabled) { 38 | parent.clicked() 39 | } 40 | } 41 | } 42 | 43 | states [ 44 | disabled when !root.enabled : { 45 | checkbox.border-color: Theme.background-light; 46 | } 47 | checked-no-hv when root.checked && !ta.has-hover : { 48 | checkbox.border-width: 0; 49 | checkbox.background: Theme.accent; 50 | } 51 | 52 | checked-hv when root.checked && ta.has-hover : { 53 | checkbox.border-width: 2px; 54 | checkbox.background: Theme.accent; 55 | checkbox.border-color: Theme.background-lightest; 56 | } 57 | 58 | unchecked-no-hv when !root.checked && !ta.has-hover : { 59 | checkbox.border-color: Theme.background-lightest; 60 | } 61 | 62 | unchecked-hv when !root.checked && ta.has-hover : { 63 | checkbox.border-color: Theme.accent; 64 | } 65 | ] 66 | } 67 | 68 | 69 | component ShortCutTag inherits HorizontalLayout { 70 | in property label; 71 | out property editing; 72 | in property enabled: true; 73 | 74 | callback new-ghk(KeyEvent); 75 | 76 | Rectangle { 77 | height: 20px; 78 | y: (parent.height - self.height)/2; 79 | width: 120px; 80 | border-width: ghk-focus.has-focus ? 2px : 0px; 81 | border-color: Theme.background-lightest; 82 | border-radius: 3px; 83 | background: ghk-focus.has-focus ? Theme.background : Theme.background-light; 84 | 85 | ghk-txt := Text { 86 | text: root.label; 87 | font-family: "Roboto Mono"; 88 | color: root.enabled ? Theme.accent : Theme.background; 89 | } 90 | 91 | ta := TouchArea { 92 | mouse-cursor: root.enabled ? (self.has-hover ? text : default) : not-allowed; 93 | clicked => { 94 | if (enabled) { 95 | if (!ghk-focus.enabled) { 96 | ghk-focus.enabled = true; 97 | ghk-focus.focus(); 98 | } else { 99 | ghk-focus.enabled = false; 100 | } 101 | } 102 | } 103 | 104 | ghk-focus := FocusScope { 105 | enabled: false; 106 | key-released(event) => { 107 | self.enabled = false; 108 | /*if (event.text == Key.Tab || event.text == Key.Escape) 109 | { 110 | return reject; 111 | }*/ 112 | new-ghk(event); 113 | accept 114 | } 115 | } 116 | } 117 | } 118 | } 119 | 120 | component ConfigBar inherits Rectangle { 121 | in property label; 122 | in-out property hidden: false; 123 | in property enabled: true; 124 | in property animate-in; 125 | in property animate-out; 126 | 127 | height: 44px; 128 | width: 100%; 129 | background: Theme.background-light; 130 | border-radius: 5px; 131 | 132 | Rectangle { 133 | height: parent.height; 134 | width: parent.width; 135 | background: Theme.background; 136 | border-radius: parent.border-radius; 137 | 138 | HorizontalBox { 139 | Text { 140 | vertical-alignment: center; 141 | text: root.label; 142 | font-size: 11pt; 143 | 144 | states [ 145 | disabled when !root.enabled : { 146 | color: Theme.background-light; 147 | } 148 | enabled when root.enabled : { 149 | color: Theme.background-lightest; 150 | } 151 | 152 | ] 153 | } 154 | 155 | @children 156 | //So in Slint 1.6 the modification to the way children are added this now is causing the layout 157 | //to alter, and pushing the "check box" to the left even though the tool tip isn't visible. 158 | //I initially had the tooltip after @children so it would end up above them in the Z order. 159 | //Might need to look into some other options like manually setting the Z-order, but since 160 | //the tooltips aren't fully functional right now I'll just comment this out. 161 | /*if Settings.is-wayland : ToolTip { 162 | text: "Unsupported under Wayland"; 163 | }*/ 164 | } 165 | 166 | states [ 167 | ani_out when animate-out : { 168 | x: -parent.width * 1.1; 169 | 170 | in { 171 | animate x { 172 | duration: 350ms; 173 | easing: ease-in-out; 174 | } 175 | } 176 | } 177 | 178 | ani_in when animate-in && enabled : { 179 | x: -parent.width * 1.1; 180 | } 181 | 182 | stable when enabled : { 183 | x: 0; 184 | 185 | in { 186 | animate x { 187 | duration: 350ms; 188 | easing: ease-in-out; 189 | } 190 | } 191 | } 192 | ] 193 | } 194 | } 195 | 196 | 197 | export component ConfigPage inherits Rectangle { 198 | background: Theme.background-light; 199 | out property active-theme: 0; 200 | in property <[ConfigData]> configs: ConfigCallbacks.configs; 201 | 202 | VerticalBox { 203 | ScrollView { 204 | VerticalBox { 205 | Text { 206 | text: "Settings"; 207 | horizontal-alignment: center; 208 | font-size: 11pt; 209 | color: Theme.foreground; 210 | } 211 | 212 | for setting[idx] in root.configs : ConfigBar { 213 | label: setting.name; 214 | hidden: false; 215 | enabled: setting.enabled; 216 | animate-in: setting.animate-in; 217 | animate-out: setting.animate-out; 218 | CheckBox { 219 | checked: setting.state; 220 | enabled: parent.enabled; 221 | clicked => { 222 | Settings.bool-changed(setting.sett-param, self.checked); 223 | } 224 | } 225 | } 226 | 227 | Text { 228 | text: "Global Shortcuts"; 229 | horizontal-alignment: center; 230 | font-size: 11pt; 231 | color: Theme.foreground; 232 | } 233 | 234 | for setting[idx] in [ 235 | {lbl: "Toggle Timer", shortcut: Settings.tt-ghk, ghk: GHKShortcuts.toggle-timer, enabled: !Settings.is-wayland}, 236 | {lbl: "Reset Timer", shortcut: Settings.rst-ghk, ghk: GHKShortcuts.reset-timer, enabled: !Settings.is-wayland}, 237 | {lbl: "Skip Round", shortcut: Settings.skp-ghk, ghk: GHKShortcuts.skip-round, enabled: !Settings.is-wayland}, 238 | ] : ConfigBar { 239 | label: setting.lbl; 240 | ShortCutTag { 241 | label: setting.shortcut; 242 | enabled: setting.enabled; 243 | new-ghk(event) => {ConfigCallbacks.new-ghk(setting.ghk, event)} 244 | } 245 | } 246 | } 247 | } 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /ui/appwindow.slint: -------------------------------------------------------------------------------- 1 | import { VerticalBox , HorizontalBox, Button, ListView } from "std-widgets.slint"; 2 | import { BorderlessWindow } from "borderless-window.slint"; 3 | import { Theme, JsonTheme, Settings, IntSettTypes } from "globals.slint"; 4 | import { SlideOver } from "slidover.slint"; 5 | import { HLClick } from "hyperlink.slint"; 6 | import { ThemeCallbacks } from "themeconfig.slint"; 7 | import { Slider } from "slider.slint"; 8 | import { TimerConfig } from "timerconfig.slint"; 9 | import { ConfigCallbacks } from "config.slint"; 10 | import { ToolTip, TTPosition } from "tooltip.slint"; 11 | export { HLClick, Theme, JsonTheme, Settings, ThemeCallbacks, ConfigCallbacks } 12 | import { CircularProgress } from "circular-progress.slint"; 13 | 14 | export enum ActiveTimer { 15 | focus, 16 | short-break, 17 | long-break, 18 | } 19 | 20 | export enum TimerAction { 21 | start, 22 | stop, 23 | reset, 24 | skip, 25 | } 26 | 27 | export component Main inherits BorderlessWindow { 28 | title: "Tomotroid"; 29 | width: 360px; 30 | height: 480px; 31 | always-on-top: on_top(); 32 | 33 | in-out property logo <=> slideover.logo; 34 | 35 | in property target-time: root.tmr-config.focus-time; 36 | in-out property remaining-time: root.target-time; 37 | 38 | out property volume: Settings.volume; 39 | out property tmr-config: slideover.tmr-config; 40 | in-out property active-timer: focus; 41 | in property active-round: 1; 42 | in-out property running: false; 43 | 44 | 45 | callback close-window(); 46 | callback minimize-window(); 47 | callback move-window(); 48 | 49 | callback action-timer(TimerAction); 50 | 51 | callback tick(duration); 52 | callback change-timer(); 53 | 54 | close => { 55 | root.close-window(); 56 | } 57 | 58 | minimize => { 59 | root.minimize-window(); 60 | } 61 | 62 | move() => { 63 | root.move-window(); 64 | } 65 | 66 | menu-toggled => { 67 | slideover.expanded = self.menu-open; 68 | } 69 | 70 | tick(passed-time) => { 71 | root.remaining-time = max(root.remaining-time - passed-time, 0); 72 | 73 | if (root.remaining-time == 0) { 74 | change-timer(); 75 | } 76 | } 77 | 78 | function get_prog_color() -> color { 79 | if (root.active-timer == ActiveTimer.focus) { 80 | Theme.focus-round 81 | } else if (root.active-timer == ActiveTimer.short-break) { 82 | Theme.short-round 83 | } else { 84 | Theme.long-round 85 | } 86 | } 87 | 88 | function time-remaining() -> string { 89 | floor(root.remaining-time / 60s) + ":" + (mod(root.remaining-time, 60s)/1s < 10 ? "0" : "") + floor(mod(root.remaining-time, 60s)/1s) 90 | } 91 | 92 | function current-timer-string() -> string { 93 | if (root.active-timer == ActiveTimer.focus) { 94 | "FOCUS" 95 | } else if (root.active-timer == ActiveTimer.short-break) { 96 | "SHORT BREAK" 97 | } else { 98 | "LONG BREAK" 99 | } 100 | } 101 | 102 | function on_top() -> bool { 103 | Settings.always-on-top 104 | && 105 | ( 106 | active-timer == ActiveTimer.focus 107 | || 108 | ( 109 | !Settings.break-always-on-top 110 | ) 111 | ) 112 | } 113 | 114 | vol-popup := PopupWindow { 115 | sldr := Slider { 116 | width: parent.width; 117 | height: 100%; 118 | value <=> root.volume; 119 | minimum: 0; 120 | maximum: 100; 121 | color: Theme.background-lightest; 122 | ft-color: Theme.background-lightest; 123 | ghv-color: Theme.accent; 124 | mt-color: Theme.background-lightest; 125 | vertical: true; 126 | 127 | value-changed(int) => { 128 | Settings.int-changed(IntSettTypes.Volume, int); 129 | } 130 | } 131 | x: 310px; 132 | y: 270px; 133 | height: 100px; 134 | width: 20px; 135 | } 136 | 137 | VerticalLayout { 138 | states [ 139 | vis when root.menu-open : { 140 | opacity: 0; 141 | in { 142 | animate opacity { 143 | duration: 100ms; 144 | delay: 0ms; 145 | easing: ease-in-out; 146 | } 147 | } 148 | } 149 | not-vis when !root.menu-open: { 150 | opacity: 1; 151 | in { 152 | animate opacity { 153 | duration: 100ms; 154 | delay: 250ms; 155 | easing: ease-in-out; 156 | } 157 | } 158 | } 159 | ] 160 | 161 | HorizontalLayout { 162 | padding-top: 60px; 163 | padding-left: 50px; 164 | padding-right: 50px; 165 | 166 | alignment: center; 167 | VerticalBox { 168 | height: 230px; 169 | width: 230px; 170 | circ-prog := CircularProgress { 171 | height: self.width; 172 | progress: remaining-time / target-time; 173 | bg_color: Theme.background-lightest; 174 | fg_color: root.get_prog_color(); 175 | txt_color: Theme.foreground; 176 | prog_text: root.time-remaining(); 177 | lbl_text: current-timer-string(); 178 | } 179 | } 180 | } 181 | 182 | //In Pomotroid there is a transition between play/pause where the whole circle fades out then back in 183 | //I'm not sure how I can actually get this to fade out then in unless I have the play / pause as 2 184 | //seperate buttons, because the transition is essentially from full opactity to full opacity. Since 185 | //it's a boolean, either running or not...how could I add a third state in between with 0 opacity? 186 | //as this is cosmetic, I'll leave it alone for now, and maybe come 187 | //back to it later when I have a mostly functioning program. 188 | HorizontalLayout { 189 | alignment: center; 190 | padding: 20px; 191 | //opacity: 0.05; 192 | //opacity: root.running ? 1 : 0.99; 193 | 194 | /*animate opacity { 195 | duration: 1000ms; 196 | //easing: ease-in-out; 197 | //easing: ease-in-out-back; 198 | easing: cubic-bezier(0,2.04,0.53,-1.31); 199 | }*/ 200 | 201 | states [ 202 | rng when root.running : { 203 | opacity: 1; 204 | out { 205 | animate opacity { 206 | duration: 250ms; 207 | //easing: ease-out; 208 | //easing: ease-in-out-back; 209 | easing: cubic-bezier(0,2.04,0.53,-1.31); 210 | } 211 | } 212 | in { 213 | animate opacity { 214 | duration: 250ms; 215 | //easing: ease-in; 216 | easing: ease-in-out-back; 217 | } 218 | } 219 | } 220 | 221 | ntrng when !root.running : { 222 | //opacity: 0.05; 223 | opacity: 1; 224 | out { 225 | animate opacity { 226 | duration: 250ms; 227 | //easing: ease-out; 228 | easing: ease-in-out-back; 229 | } 230 | } 231 | in { 232 | animate opacity { 233 | duration: 250ms; 234 | //easing: ease-in; 235 | easing: ease-in-out-back; 236 | } 237 | } 238 | } 239 | ] 240 | 241 | Rectangle { 242 | border-color: Theme.foreground-darkest; 243 | border-width: 2px; 244 | border-radius: self.height*0.5; 245 | height: 50px; 246 | width: 50px; 247 | 248 | animate background { 249 | duration: 200ms; 250 | easing: ease-in-out; 251 | } 252 | 253 | StartBtn-ta := TouchArea { 254 | clicked => { 255 | if (root.running) { 256 | root.action-timer(TimerAction.stop); 257 | } else { 258 | root.action-timer(TimerAction.start); 259 | } 260 | } 261 | } 262 | 263 | Image { 264 | source: root.running ? @image-url("../assets/icons/pause.svg") : @image-url("../assets/icons/start.svg"); 265 | animate colorize { 266 | duration: 200ms; 267 | easing: ease-in-out; 268 | } 269 | 270 | states [ 271 | hvr when StartBtn-ta.has-hover : { 272 | colorize: Theme.accent; 273 | } 274 | 275 | nthvr when !StartBtn-ta.has-hover : { 276 | colorize: Theme.foreground; 277 | } 278 | ] 279 | } 280 | 281 | states [ 282 | hvr when StartBtn-ta.has-hover : { 283 | background: Theme.background.brighter(0.2); 284 | } 285 | nthvr when !StartBtn-ta.has-hover : { 286 | background: Theme.background; 287 | } 288 | ] 289 | } 290 | } 291 | HorizontalLayout { 292 | alignment: space-between; 293 | padding-top: -10px; 294 | padding-left: 20px; 295 | padding-right: 15px; 296 | padding-bottom: -10px; 297 | Text { 298 | font-family: "Lato"; 299 | font-weight: 900; 300 | text: "\{root.active-round}/\{root.tmr-config.rounds}"; 301 | font-size: 16px; 302 | color: Theme.foreground-darker; 303 | vertical-alignment: center; 304 | } 305 | HorizontalBox { 306 | min-width: 80px; 307 | Rectangle { 308 | y: parent.height/2 - self.height/2; 309 | SkipBtn-ta := TouchArea { 310 | clicked => { 311 | action-timer(TimerAction.skip) 312 | } 313 | } 314 | Image { 315 | source: @image-url("../assets/icons/skip.svg"); 316 | height: 20px; 317 | animate colorize { 318 | duration: 250ms; 319 | easing: ease-in-out; 320 | } 321 | 322 | states [ 323 | hvr when SkipBtn-ta.has-hover : { 324 | colorize: Theme.accent; 325 | } 326 | 327 | nthvr when !SkipBtn-ta.has-hover : { 328 | colorize: Theme.background-lightest; 329 | } 330 | ] 331 | } 332 | } 333 | 334 | Rectangle { 335 | y: parent.height/2 - self.height/2; 336 | MuteBtn-ta := TouchArea { 337 | clicked => { 338 | vol-popup.show(); 339 | } 340 | } 341 | mt-img := Image { 342 | height: 20px; 343 | animate colorize { 344 | duration: 250ms; 345 | easing: ease-in-out; 346 | } 347 | 348 | states [ 349 | muted when root.volume == 0 : { 350 | source: @image-url("../assets/icons/muted.svg"); 351 | } 352 | 353 | audible when root.volume > 0 : { 354 | source: @image-url("../assets/icons/mute.svg"); 355 | } 356 | ] 357 | } 358 | 359 | states [ 360 | hvr when MuteBtn-ta.has-hover : { 361 | mt-img.colorize: Theme.accent; 362 | } 363 | 364 | nthvr when !MuteBtn-ta.has-hover : { 365 | mt-img.colorize: Theme.background-lightest; 366 | } 367 | ] 368 | } 369 | } 370 | } 371 | 372 | HorizontalLayout { 373 | padding-top: 5px; 374 | padding-left: 15px; 375 | padding-right: 15px; 376 | alignment: start; 377 | Text { 378 | ResetBtn-ta := TouchArea { 379 | clicked => { 380 | action-timer(TimerAction.reset) 381 | } 382 | } 383 | font-weight: 900; 384 | text: "Reset"; 385 | font-size: 14px; 386 | 387 | states [ 388 | hvr when ResetBtn-ta.has-hover : { 389 | color: Theme.accent; 390 | } 391 | 392 | nthvr when !ResetBtn-ta.has-hover : { 393 | color: Theme.foreground-darker; 394 | } 395 | ] 396 | } 397 | } 398 | } 399 | 400 | skip-tt := ToolTip { 401 | owner-x: SkipBtn-ta.absolute-position.x; 402 | owner-y: SkipBtn-ta.absolute-position.y - 38px/*title bar height*/; 403 | owner-height: SkipBtn-ta.height; 404 | owner-width: SkipBtn-ta.width; 405 | position: TTPosition.Bottom; 406 | show: SkipBtn-ta.has-hover; 407 | text: "Skip the Current Round"; 408 | } 409 | 410 | mute-tt := ToolTip { 411 | owner-x: MuteBtn-ta.absolute-position.x; 412 | owner-y: MuteBtn-ta.absolute-position.y - 38px/*title bar height*/; 413 | owner-height: MuteBtn-ta.height; 414 | owner-width: MuteBtn-ta.width; 415 | position: TTPosition.Bottom; 416 | show: MuteBtn-ta.has-hover; 417 | text: "Mute"; 418 | } 419 | 420 | reset-tt := ToolTip { 421 | owner-x: ResetBtn-ta.absolute-position.x; 422 | owner-y: ResetBtn-ta.absolute-position.y - 38px/*title bar height*/; 423 | owner-height: ResetBtn-ta.height; 424 | owner-width: ResetBtn-ta.width; 425 | position: TTPosition.Right; 426 | show: ResetBtn-ta.has-hover; 427 | text: "Reset Current Round"; 428 | } 429 | 430 | slideover := SlideOver { 431 | x: 0px; 432 | y: 30px; 433 | width: root.width; 434 | height: parent.height - 68px; 435 | expanded: false; 436 | } 437 | } 438 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn( 2 | clippy::all, 3 | clippy::pedantic, 4 | //clippy::cargo, 5 | )] 6 | #![windows_subsystem = "windows"] 7 | 8 | mod settings; 9 | mod setup; 10 | 11 | use crate::setup::TrayMsg; 12 | 13 | use anyhow::Result; 14 | use global_hotkey::GlobalHotKeyEvent; 15 | use global_hotkey::{ 16 | hotkey::{Code, HotKey}, 17 | GlobalHotKeyManager, 18 | }; 19 | use notify_rust::Notification; 20 | use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink}; 21 | use settings::{get_non_print_key_txt, GlobalShortcuts, JsonHotKey, JsonSettings}; 22 | use single_instance::SingleInstance; 23 | use slint::{ 24 | platform::Key, Model, ModelRc, PlatformError, SharedString, Timer, TimerMode, VecModel, 25 | }; 26 | use std::io::Cursor; 27 | use std::{borrow::Borrow, rc::Rc, str::FromStr}; 28 | 29 | use log::{error, info, warn}; 30 | 31 | slint::include_modules!(); 32 | 33 | pub const ALERT_LONG_BREAK: &[u8] = include_bytes!("../assets/audio/alert-long-break.ogg"); 34 | pub const ALERT_SHORT_BREAK: &[u8] = include_bytes!("../assets/audio/alert-short-break.ogg"); 35 | pub const ALERT_WORK: &[u8] = include_bytes!("../assets/audio/alert-work.ogg"); 36 | pub const TICK: &[u8] = include_bytes!("../assets/audio/tick.ogg"); 37 | 38 | impl Main { 39 | fn set_settings(&self, settings: &JsonSettings) { 40 | self.global::() 41 | .set_always_on_top(settings.always_on_top); 42 | self.global::() 43 | .set_auto_start_break_timer(settings.auto_start_break_timer); 44 | self.global::() 45 | .set_auto_start_work_timer(settings.auto_start_work_timer); 46 | self.global::() 47 | .set_break_always_on_top(settings.break_always_on_top); 48 | 49 | //Global Shortcuts 50 | self.global::() 51 | .set_tt_ghk(settings.global_shortcuts.toggle.to_string().into()); 52 | self.global::() 53 | .set_rst_ghk(settings.global_shortcuts.reset.to_string().into()); 54 | self.global::() 55 | .set_skp_ghk(settings.global_shortcuts.skip.to_string().into()); 56 | 57 | self.global::() 58 | .set_min_to_tray(settings.min_to_tray); 59 | self.global::() 60 | .set_min_to_tray_on_close(settings.min_to_tray_on_close); 61 | self.global::() 62 | .set_notifications(settings.notifications); 63 | self.global::() 64 | .set_theme((&settings.theme).into()); 65 | self.global::() 66 | .set_tick_sounds(settings.tick_sounds); 67 | self.global::() 68 | .set_tick_sounds_during_break(settings.tick_sounds_during_break); 69 | self.global::() 70 | .set_time_long_break(settings.time_long_break); 71 | self.global::() 72 | .set_time_short_break(settings.time_short_break); 73 | self.global::().set_time_work(settings.time_work); 74 | self.global::().set_volume(settings.volume); 75 | self.global::() 76 | .set_work_rounds(settings.work_rounds); 77 | 78 | self.global::() 79 | .set_is_wayland(settings::is_wayland()); 80 | } 81 | 82 | fn save_settings(&self) { 83 | settings::save_settings(&JsonSettings { 84 | always_on_top: self.global::().get_always_on_top(), 85 | auto_start_break_timer: self.global::().get_auto_start_break_timer(), 86 | auto_start_work_timer: self.global::().get_auto_start_work_timer(), 87 | break_always_on_top: self.global::().get_break_always_on_top(), 88 | 89 | global_shortcuts: GlobalShortcuts { 90 | reset: JsonHotKey::from_str( 91 | self.global::().get_rst_ghk().to_string().as_str(), 92 | ) 93 | .expect("a valid Timer Reset GHK"), 94 | skip: JsonHotKey::from_str( 95 | self.global::().get_skp_ghk().to_string().as_str(), 96 | ) 97 | .expect("a valid Timer Skip GHK"), 98 | toggle: JsonHotKey::from_str( 99 | self.global::().get_tt_ghk().to_string().as_str(), 100 | ) 101 | .expect("a valid Timer Toggle GHK"), 102 | }, 103 | 104 | min_to_tray: self.global::().get_min_to_tray(), 105 | min_to_tray_on_close: self.global::().get_min_to_tray_on_close(), 106 | notifications: self.global::().get_notifications(), 107 | 108 | theme: self.global::().get_theme().to_string(), 109 | 110 | tick_sounds: self.global::().get_tick_sounds(), 111 | tick_sounds_during_break: self.global::().get_tick_sounds_during_break(), 112 | time_long_break: self.global::().get_time_long_break(), 113 | time_short_break: self.global::().get_time_short_break(), 114 | time_work: self.global::().get_time_work(), 115 | volume: self.global::().get_volume(), 116 | work_rounds: self.global::().get_work_rounds(), 117 | }); 118 | } 119 | } 120 | 121 | struct Tomotroid { 122 | pub window: Main, 123 | settings: JsonSettings, 124 | reset: Option, 125 | skip: Option, 126 | toggle: Option, 127 | ghk_manager: GlobalHotKeyManager, 128 | audio_stream: OutputStream, 129 | audio_handle: OutputStreamHandle, 130 | audio_sink: Rc, 131 | config_model: Rc>, 132 | } 133 | 134 | impl Tomotroid { 135 | fn new() -> Self { 136 | let settings = settings::load_settings(); 137 | let themes = settings::load_themes(); 138 | 139 | let ghk_manager = GlobalHotKeyManager::new().unwrap(); 140 | let toggle = &settings.global_shortcuts.toggle.borrow().into(); 141 | let reset = &settings.global_shortcuts.reset.borrow().into(); 142 | let skip = &settings.global_shortcuts.skip.borrow().into(); 143 | 144 | let toggle = ghk_manager 145 | .register(*toggle) 146 | .map_or(None, |()| Some(*toggle)); 147 | let reset = ghk_manager.register(*reset).map_or(None, |()| Some(*reset)); 148 | let skip = ghk_manager.register(*skip).map_or(None, |()| Some(*skip)); 149 | 150 | let (audio_stream, audio_handle) = OutputStream::try_default().unwrap(); 151 | let audio_sink = Rc::new(Sink::try_new(&audio_handle).unwrap()); 152 | audio_sink.set_volume(settings.volume as f32 / 100.0); 153 | 154 | let window = Main::new().unwrap(); 155 | window.set_settings(&settings); 156 | 157 | let theme_model: Rc> = Rc::new(VecModel::from(themes)); 158 | window 159 | .global::() 160 | .set_themes(ModelRc::from(theme_model.clone())); 161 | 162 | let config_model = Rc::new(VecModel::from(vec![ 163 | ConfigData { 164 | name: "Always On Top".into(), 165 | state: settings.always_on_top, 166 | sett_param: BoolSettTypes::AlwOnTop, 167 | enabled: !settings::is_wayland(), 168 | animate_in: false, 169 | animate_out: false, 170 | }, 171 | ConfigData { 172 | name: "Deactivate Always On Top on Breaks".into(), 173 | state: settings.break_always_on_top, 174 | sett_param: BoolSettTypes::BrkAlwOnTop, 175 | enabled: !settings::is_wayland() && settings.always_on_top, 176 | animate_in: false, 177 | animate_out: false, 178 | }, //only shown when "Always On Top" is selected 179 | ConfigData { 180 | name: "Auto-start Work Timer".into(), 181 | state: settings.auto_start_work_timer, 182 | sett_param: BoolSettTypes::AutoStrtWrkTim, 183 | enabled: true, 184 | animate_in: false, 185 | animate_out: false, 186 | }, 187 | ConfigData { 188 | name: "Auto-start Break Timer".into(), 189 | state: settings.auto_start_break_timer, 190 | sett_param: BoolSettTypes::AutoStrtBreakTim, 191 | enabled: true, 192 | animate_in: false, 193 | animate_out: false, 194 | }, 195 | ConfigData { 196 | name: "Tick Sounds - Work".into(), 197 | state: settings.tick_sounds, 198 | sett_param: BoolSettTypes::TickSounds, 199 | enabled: true, 200 | animate_in: false, 201 | animate_out: false, 202 | }, 203 | ConfigData { 204 | name: "Tick Sounds - Break".into(), 205 | state: settings.tick_sounds_during_break, 206 | sett_param: BoolSettTypes::TickSoundsBreak, 207 | enabled: true, 208 | animate_in: false, 209 | animate_out: false, 210 | }, 211 | ConfigData { 212 | name: "Desktop Notifications".into(), 213 | state: settings.notifications, 214 | sett_param: BoolSettTypes::Notifications, 215 | enabled: true, 216 | animate_in: false, 217 | animate_out: false, 218 | }, 219 | ConfigData { 220 | name: "Minimize to Tray".into(), 221 | state: settings.min_to_tray, 222 | sett_param: BoolSettTypes::MinToTray, 223 | enabled: true, 224 | animate_in: false, 225 | animate_out: false, 226 | }, 227 | ConfigData { 228 | name: "Minimize to Tray on Close".into(), 229 | state: settings.min_to_tray_on_close, 230 | sett_param: BoolSettTypes::MinToTryCls, 231 | enabled: true, 232 | animate_in: false, 233 | animate_out: false, 234 | }, 235 | ])); 236 | 237 | //window.global::().set_configs(ModelRc::new(config_model.clone().filter(|cf| cf.enabled))); 238 | 239 | Self { 240 | window, 241 | settings, 242 | reset, 243 | skip, 244 | toggle, 245 | ghk_manager, 246 | audio_stream, 247 | audio_handle, 248 | audio_sink, 249 | config_model, 250 | } 251 | } 252 | 253 | fn run(&self) -> Result<(), PlatformError> { 254 | let thm_name = &self.settings.theme; 255 | let themes = self.window.global::().get_themes(); 256 | let (idx, cur_theme) = themes 257 | .iter() 258 | .enumerate() 259 | .find(|(_, thm)| thm.name == thm_name) 260 | .unwrap(); 261 | self.window 262 | .global::() 263 | .invoke_theme_changed(idx as i32, cur_theme.clone()); 264 | 265 | self.window.run() 266 | } 267 | } 268 | 269 | impl BoolSettTypes { 270 | #[must_use] 271 | pub fn to_usize(&self) -> usize { 272 | match self { 273 | BoolSettTypes::AlwOnTop => 0, 274 | BoolSettTypes::BrkAlwOnTop => 1, 275 | BoolSettTypes::AutoStrtWrkTim => 2, 276 | BoolSettTypes::AutoStrtBreakTim => 3, 277 | BoolSettTypes::TickSounds => 4, 278 | BoolSettTypes::TickSoundsBreak => 5, 279 | BoolSettTypes::Notifications => 6, 280 | BoolSettTypes::MinToTray => 7, 281 | BoolSettTypes::MinToTryCls => 8, 282 | } 283 | } 284 | } 285 | 286 | //eventually I want to clean this main up and make it smaller, but for now I'll just 287 | //surpress this clippy warning 288 | #[allow(clippy::too_many_lines)] 289 | fn main() -> Result<()> { 290 | setup::logging(); 291 | info!("Starting up"); 292 | 293 | let instance = SingleInstance::new("org.vadoola.tomotroid").unwrap(); 294 | if !instance.is_single() { 295 | error!("Only one instance of Tomotroid is allowed to run"); 296 | return Err(anyhow::anyhow!( 297 | "Only one instance of Tomotroid is allowed to run" 298 | )); 299 | } 300 | 301 | let tray_rx = setup::tray().unwrap(); 302 | 303 | setup::backend(); 304 | 305 | let tomotroid = Tomotroid::new(); 306 | let config_model = tomotroid.config_model.clone(); 307 | let set_handle = tomotroid.window.as_weak(); 308 | let filt_mod = Rc::new(ModelRc::from(tomotroid.config_model.clone()).filter(|cf| cf.enabled)); 309 | tomotroid 310 | .window 311 | .global::() 312 | .set_configs(ModelRc::from(filt_mod.clone())); 313 | 314 | tomotroid 315 | .window 316 | .global::() 317 | .on_bool_changed(move |set_type, val| { 318 | settings::bool_changed(&set_handle, &config_model, set_type, val); 319 | }); 320 | 321 | let ghk_handle = tomotroid.window.as_weak(); 322 | let ghk_receiver = GlobalHotKeyEvent::receiver(); 323 | let _thread = std::thread::spawn(move || loop { 324 | let ghk_handle2 = ghk_handle.clone(); 325 | slint::invoke_from_event_loop(move || { 326 | if let Ok(event) = ghk_receiver.try_recv() { 327 | if event.state() == global_hotkey::HotKeyState::Released { 328 | let ghk_handle2 = ghk_handle2.upgrade().unwrap(); 329 | match event.id() { 330 | tg_id if tomotroid.toggle.is_some_and(|toggle| toggle.id() == tg_id) => { 331 | let action = if ghk_handle2.get_running() { 332 | TimerAction::Stop 333 | } else { 334 | TimerAction::Start 335 | }; 336 | ghk_handle2.invoke_action_timer(action); 337 | } 338 | rst_id if tomotroid.reset.is_some_and(|reset| reset.id() == rst_id) => { 339 | ghk_handle2.invoke_action_timer(TimerAction::Reset); 340 | } 341 | skp_id if tomotroid.skip.is_some_and(|skip| skip.id() == skp_id) => { 342 | ghk_handle2.invoke_action_timer(TimerAction::Skip); 343 | } 344 | _ => {} 345 | } 346 | } 347 | } 348 | }) 349 | .unwrap(); 350 | std::thread::sleep(std::time::Duration::from_millis(500)); 351 | }); 352 | 353 | let vol_sink = tomotroid.audio_sink.clone(); 354 | let set_int_handle = tomotroid.window.as_weak(); 355 | tomotroid 356 | .window 357 | .global::() 358 | .on_int_changed(move |set_type, val| { 359 | settings::int_changed(&set_int_handle, &vol_sink, set_type, val); 360 | }); 361 | 362 | let close_handle = tomotroid.window.as_weak(); 363 | tomotroid.window.on_close_window(move || { 364 | let close_handle = close_handle.upgrade().unwrap(); 365 | close_handle.save_settings(); 366 | 367 | close_handle.hide().unwrap(); 368 | 369 | //After I get the system tray working I'm going to want to hide the window instead of actually close it 370 | //if it's set to hide on close 371 | //i_slint_backend_winit::WinitWindowAccessor::with_winit_window(min_handle.window(), |win| win.set_visible(false)); 372 | }); 373 | 374 | let min_handle = tomotroid.window.as_weak(); 375 | tomotroid.window.on_minimize_window(move || { 376 | let min_handle = min_handle.upgrade().unwrap(); 377 | min_handle.window().set_minimized(true); 378 | }); 379 | 380 | let move_handle = tomotroid.window.as_weak(); 381 | tomotroid.window.on_move_window(move || { 382 | let move_handle = move_handle.upgrade().unwrap(); 383 | i_slint_backend_winit::WinitWindowAccessor::with_winit_window( 384 | move_handle.window(), 385 | i_slint_backend_winit::winit::window::Window::drag_window, 386 | ); 387 | }); 388 | 389 | let tray_handle = tomotroid.window.as_weak(); 390 | let _tray_rec_thread = std::thread::spawn(move || loop { 391 | match tray_rx.recv() { 392 | Ok(TrayMsg::MinRes) => { 393 | let tray_handle_copy = tray_handle.clone(); 394 | slint::invoke_from_event_loop(move || { 395 | let main = tray_handle_copy.upgrade().unwrap(); 396 | main.window().set_minimized(false); 397 | i_slint_backend_winit::WinitWindowAccessor::with_winit_window( 398 | main.window(), 399 | |win| { 400 | //win.set_minimized(!win.is_minimized().unwrap()); 401 | win.focus_window(); 402 | }, 403 | ); 404 | }) 405 | .unwrap(); 406 | } 407 | Ok(TrayMsg::Quit) => { 408 | let tray_handle_copy = tray_handle.clone(); 409 | slint::invoke_from_event_loop(move || { 410 | tray_handle_copy.upgrade().unwrap().hide().unwrap(); 411 | }) 412 | .unwrap(); 413 | } 414 | _ => {} 415 | } 416 | }); 417 | 418 | tomotroid.window.global::().on_hl_clicked(|url| { 419 | open::that(url.as_str()).unwrap(); 420 | }); 421 | 422 | let thm_handle = tomotroid.window.as_weak(); 423 | tomotroid 424 | .window 425 | .global::() 426 | .on_theme_changed(move |idx, theme| settings::theme_changed(&thm_handle, idx, theme)); 427 | 428 | let timer = Timer::default(); 429 | 430 | //This tick count is a quick hacky way to keep track of how many times 431 | //the timer has been called...it's really not ideal, but I need to know 432 | //so I can call the tick sound every 20 calls of the timer (ie 1s) 433 | //If slint ever adds the rounded line caps, and I update the progress circle 434 | //to be a Slint component instead of an SVG I can use animation 435 | //to make this circle animate smoothly but only have the time trigger 436 | //once per second. Until then I need to track how many times the timer 437 | //is called. I will use this quick hacky method for now to get the basic 438 | //sound logic in and working. There is probably a better way to handle this even 439 | //before Slint adds support for line caps in paths, but I'll come back to it. 440 | let mut tick_count = 0u32; 441 | let tick_sink = tomotroid.audio_sink.clone(); 442 | let timer_handle = tomotroid.window.as_weak(); 443 | tomotroid.window.on_action_timer(move |action| { 444 | //Notification::new().summary("Performing an Action").show().unwrap(); 445 | let tmrstrt_handle = timer_handle.clone(); 446 | let timer_handle = timer_handle.upgrade().unwrap(); 447 | let tick_sink = tick_sink.clone(); 448 | match action { 449 | TimerAction::Start => { 450 | timer_handle.set_running(true); 451 | timer.start( 452 | TimerMode::Repeated, 453 | std::time::Duration::from_millis(1000), 454 | move || { 455 | let tmrstrt_handle = tmrstrt_handle.unwrap(); 456 | 457 | if tick_count >= 20 { 458 | tick_count = 0; 459 | let is_work_timer = 460 | tmrstrt_handle.get_active_timer() == ActiveTimer::Focus; 461 | if tmrstrt_handle.global::().get_tick_sounds() 462 | && is_work_timer 463 | { 464 | let source = Decoder::new(Cursor::new(TICK)).unwrap(); 465 | tick_sink.append(source); 466 | } else if tmrstrt_handle 467 | .global::() 468 | .get_tick_sounds_during_break() 469 | && !is_work_timer 470 | { 471 | let source = Decoder::new(Cursor::new(TICK)).unwrap(); 472 | tick_sink.append(source); 473 | } 474 | } else { 475 | tick_count += 1; 476 | } 477 | 478 | tmrstrt_handle.invoke_tick(1000); 479 | }, 480 | ); 481 | } 482 | TimerAction::Stop => { 483 | timer_handle.set_running(false); 484 | timer.stop(); 485 | } 486 | TimerAction::Reset => { 487 | timer.stop(); 488 | timer_handle.set_running(false); 489 | timer_handle.set_remaining_time(timer_handle.get_target_time()); 490 | } 491 | TimerAction::Skip => { 492 | //timer_handle.set_remaining_time(0); 493 | timer_handle.invoke_change_timer(); 494 | } 495 | } 496 | }); 497 | 498 | let tmr_change_sink = tomotroid.audio_sink.clone(); 499 | let chg_tmr_handle = tomotroid.window.as_weak(); 500 | tomotroid.window.on_change_timer(move || { 501 | let chg_tmr_handle = chg_tmr_handle.upgrade().unwrap(); 502 | match chg_tmr_handle.get_active_timer() { 503 | ActiveTimer::Focus => { 504 | if !chg_tmr_handle 505 | .global::() 506 | .get_auto_start_break_timer() 507 | { 508 | chg_tmr_handle.invoke_action_timer(TimerAction::Stop); 509 | } 510 | let body_str = if chg_tmr_handle.get_active_round() 511 | == chg_tmr_handle.get_tmr_config().rounds 512 | { 513 | let source = Decoder::new(Cursor::new(ALERT_LONG_BREAK)).unwrap(); 514 | tmr_change_sink.append(source); 515 | let lgbrk_time = chg_tmr_handle.get_tmr_config().lgbrk_time; 516 | 517 | chg_tmr_handle.set_active_round(1); 518 | chg_tmr_handle.set_active_timer(ActiveTimer::LongBreak); 519 | 520 | chg_tmr_handle.set_target_time(lgbrk_time); 521 | chg_tmr_handle.set_remaining_time(lgbrk_time); 522 | format!("Begin a {} minute long break.", lgbrk_time / 60000) 523 | } else { 524 | let source = Decoder::new(Cursor::new(ALERT_SHORT_BREAK)).unwrap(); 525 | tmr_change_sink.append(source); 526 | let shbrk_time = chg_tmr_handle.get_tmr_config().shbrk_time; 527 | chg_tmr_handle.set_active_timer(ActiveTimer::ShortBreak); 528 | chg_tmr_handle.set_target_time(shbrk_time); 529 | chg_tmr_handle.set_remaining_time(shbrk_time); 530 | format!("Begin a {} minute short break.", shbrk_time / 60000) 531 | }; 532 | Notification::new() 533 | //.appname("Tomotroid") 534 | //.icon("../assets/logo.png") 535 | .summary("Focus Round Complete") 536 | .body(&body_str) 537 | .show() 538 | .unwrap(); 539 | } 540 | brk_type => { 541 | if !chg_tmr_handle 542 | .global::() 543 | .get_auto_start_work_timer() 544 | { 545 | chg_tmr_handle.invoke_action_timer(TimerAction::Stop); 546 | } 547 | 548 | let focus_time = chg_tmr_handle.get_tmr_config().focus_time; 549 | let source = Decoder::new(Cursor::new(ALERT_WORK)).unwrap(); 550 | tmr_change_sink.append(source); 551 | chg_tmr_handle.set_active_round(if brk_type == ActiveTimer::ShortBreak { 552 | chg_tmr_handle.get_active_round() + 1 553 | } else { 554 | 1 555 | }); 556 | chg_tmr_handle.set_active_timer(ActiveTimer::Focus); 557 | chg_tmr_handle.set_target_time(focus_time); 558 | chg_tmr_handle.set_remaining_time(focus_time); 559 | Notification::new() 560 | //.appname("Tomotroid") 561 | //.icon("../assets/logo.png") 562 | .summary("Break Finished") 563 | .body(&format!( 564 | "Begin focusing for {} minutes.", 565 | focus_time / 60000 566 | )) 567 | .show() 568 | .unwrap(); 569 | } 570 | } 571 | }); 572 | 573 | let ghk_handle = tomotroid.window.as_weak(); 574 | tomotroid 575 | .window 576 | .global::() 577 | .on_new_ghk(move |ghk, event| { 578 | //ok so the basic concept of this is working, I'm getting the keys on release 579 | //and I'm getting the keys with the modifier...but of course I also get another firing 580 | //when I release the modifiers afterward. IE if I press left Ctrl+D, and release the D 581 | //first I get the correct combination I would expect. But then when I release the Ctrl 582 | //I get an event where the modifiers are all false, and then event text is "\x11" which 583 | //according to an Ascii chart is "Device Control 1". If I do the same with the right Ctrl 584 | //key I actually get a text of "\x16"...which is "Synchronous idle"? either way I need to 585 | //be able to filter these out?, but what I need to filter depends on the modifer used 586 | //It make sense that any global hotkey would use a modifier. It would cause problems to 587 | //just set a global hotkey to the D key, anytime you needed to type D and the program was 588 | //running you couldn't. So I had the thought to just reject all events where there are no modifiers 589 | //but what if they want the GHK to just be F8 for example....that wouldn't have any modifiers 590 | //I can't look at the repeat key, because per the documentation and my testing repeat is always 591 | //false for key release....might I have to do some sort of odd handshaking? Put in a key pressed 592 | //event look at what's pressed look for repeat, set some sort of flag and then look for a set flag 593 | //on the release? Another Option I guess would be to filter to some sort of good characters? 594 | //IE if the text isn't a-z, F-keys, etc reject it....but I could end up rejecting valid combinations easily 595 | //especially perhaps if someone has an international keyboard I would assume I would miss also sorts of valid 596 | //key combinations that I'm just unaware of for other language keyboards. 597 | 598 | //Ok this current code may not have as many issues as I thought 599 | //I added in some crude support for a setting string tied to each global shortcut, and assigned that to the 600 | //text on the Config page. This mostly seems to work, The Focus Scope even looses focus after I accept the input 601 | //There are some odities 602 | // * If I use the F1 keys I get a unicode replacement character...fair enough, that will probably need special handling 603 | // * It looks like all the Function keys come back as "xEF" though....Might need to open a discussion 604 | // * or ticket with Slint about how this would work? 605 | // * Ctrl+Shift+Char works, Shift+Alt+Char works, but Ctrl+Alt seems to add other characters...this could be a windows 606 | // * thing for example Ctrl-Alt+A inserts á...but if I hit Ctrl+Alt+A in VSCodium here I also get a á, so that must 607 | // * be something Windows 11 is doing for alternate character support. 608 | // * I haven't tested any of this on Linux yets 609 | 610 | //I guess it's reasonable to assume a global hotkey is going to be 1 or more modifiers + 1 non-modifier key? 611 | //which would eliminate this problem (by eliminate it I mean sweep it under the rug) 612 | //I guess that would also make it reasonable for the FocusScope to just reject anything where a modifier wasn't pressed 613 | 614 | let ghk_handle = ghk_handle.upgrade().unwrap(); 615 | if (!event.modifiers.control 616 | && !event.modifiers.alt 617 | && !event.modifiers.shift 618 | && !event.modifiers.meta) 619 | || event.text == SharedString::from(Key::Tab) 620 | { 621 | //this below seems wasteful resource wise. I'm setting the string to blank, and then setting it back 622 | //to the original string. In the FocusScope the focused property is out, so I can't edit it, I can only read it 623 | //which means I can't tell the FocusScope to loose focus after a new GHK is accepted, or the Esc key is pressed. 624 | //It seems to loose focus when I set new text....but it has to be NEW text. If I just call set and use the current 625 | //value it actually doesn't work...so I have to blank the string then reset it to what it was. 626 | //I tried moving the EventResult return from inside the slint code to here, and rejecting it if Esc was pressed 627 | //etc thinking that would work...but it didn't. 628 | //While wastefull from an execution and performance perspective as of right now this is the only way I can get it 629 | //working the way I want. I Might need to dig deeper into how the Focus Handling works, I'm sure there is 630 | //a better way to do this 631 | match ghk { 632 | GHKShortcuts::ToggleTimer => { 633 | let pre = ghk_handle.global::().get_tt_ghk(); 634 | ghk_handle 635 | .global::() 636 | .set_tt_ghk(SharedString::new()); 637 | ghk_handle.global::().set_tt_ghk(pre); 638 | } 639 | GHKShortcuts::ResetTimer => { 640 | let pre = ghk_handle.global::().get_rst_ghk(); 641 | ghk_handle 642 | .global::() 643 | .set_rst_ghk(SharedString::new()); 644 | ghk_handle.global::().set_rst_ghk(pre); 645 | } 646 | GHKShortcuts::SkipRound => { 647 | let pre = ghk_handle.global::().get_skp_ghk(); 648 | ghk_handle 649 | .global::() 650 | .set_skp_ghk(SharedString::new()); 651 | ghk_handle.global::().set_skp_ghk(pre); 652 | } 653 | } 654 | } else { 655 | let mut text = String::new(); 656 | if event.modifiers.control { 657 | text = "Control+".to_string(); 658 | } 659 | if event.modifiers.alt { 660 | text.push_str("Alt+"); 661 | } 662 | if event.modifiers.shift { 663 | text.push_str("Shift+"); 664 | } 665 | if event.modifiers.meta { 666 | text.push_str("Super+"); 667 | } 668 | 669 | if let Some(non_pr_char) = get_non_print_key_txt(&event.text) { 670 | text.push_str(non_pr_char); 671 | } else { 672 | text.push_str(&event.text.to_uppercase()); 673 | } 674 | 675 | match ghk { 676 | GHKShortcuts::ToggleTimer => { 677 | ghk_handle.global::().set_tt_ghk(text.into()); 678 | } 679 | GHKShortcuts::ResetTimer => { 680 | ghk_handle.global::().set_rst_ghk(text.into()); 681 | } 682 | GHKShortcuts::SkipRound => { 683 | ghk_handle.global::().set_skp_ghk(text.into()); 684 | } 685 | } 686 | ghk_handle.save_settings(); 687 | } 688 | }); 689 | 690 | tomotroid.run()?; 691 | Ok(()) 692 | } 693 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use crate::{BoolSettTypes, ConfigData, IntSettTypes, JsonTheme, Main, Settings, Theme}; 2 | use core::fmt; 3 | use directories::ProjectDirs; 4 | use global_hotkey::hotkey::{Code, HotKey, Modifiers}; 5 | use hex_color::HexColor; 6 | use rodio::Sink; 7 | use serde::{Deserialize, Serialize}; 8 | use slint::{platform::Key, Color, ComponentHandle, Model, SharedString, Timer, VecModel, Weak}; 9 | use std::{ 10 | env, 11 | fs::{File, OpenOptions}, 12 | io::{BufReader, BufWriter}, 13 | path::Path, 14 | rc::Rc, 15 | str::FromStr, 16 | sync::OnceLock, 17 | }; 18 | use walkdir::WalkDir; 19 | 20 | const LOGO_BYTES: &str = include_str!("../assets/logo.svg"); 21 | 22 | #[derive(Debug, Clone, PartialEq)] 23 | pub struct GKeyCode(Code); 24 | 25 | impl From for GKeyCode { 26 | fn from(value: Code) -> Self { 27 | GKeyCode(value) 28 | } 29 | } 30 | 31 | impl From for Code { 32 | fn from(value: GKeyCode) -> Self { 33 | value.0 34 | } 35 | } 36 | 37 | //So this massive match block to convert to a string is to keep compatibility with the Pomotroid 38 | //preferences / settings json file. Part of me was tempted to break compatibility, then I thought 39 | //well maybe break compatibility but allow for importing the Pomotroid file and converting 40 | //which would still require me manually addjusting the string format of the Code struct instead of 41 | //using the built in version...so I might as well just keep compatibility. Perhaps for a v2 if I add 42 | //other features I'll strip this and break compatibility. Honestly since there is a lot of overlap 43 | //the match block isn't even that big, because for the values that overlap I can just fallback 44 | //to use the Code's built in to/from string functions. 45 | impl fmt::Display for GKeyCode { 46 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 47 | use global_hotkey::hotkey::Code; 48 | let tmp_str = self.0.to_string(); 49 | //https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values 50 | write!( 51 | f, 52 | "{}", 53 | match self.0 { 54 | Code::Backslash => "\\", 55 | Code::BracketLeft => "[", //I've always known [ ] as brackets, but after coming to NZ I realize this is not universal, so I need to make sure this is the correct char for this enum value 56 | Code::BracketRight => "]", //I've always known [ ] as brackets, but after coming to NZ I realize this is not universal, so I need to make sure this is the correct char for this enum value 57 | Code::Comma => ",", 58 | Code::Digit0 => "0", 59 | Code::Digit1 => "1", 60 | Code::Digit2 => "2", 61 | Code::Digit3 => "3", 62 | Code::Digit4 => "4", 63 | Code::Digit5 => "5", 64 | Code::Digit6 => "6", 65 | Code::Digit7 => "7", 66 | Code::Digit8 => "8", 67 | Code::Digit9 => "9", 68 | Code::Equal => "=", 69 | Code::KeyA => "A", 70 | Code::KeyB => "B", 71 | Code::KeyC => "C", 72 | Code::KeyD => "D", 73 | Code::KeyE => "E", 74 | Code::KeyF => "F", 75 | Code::KeyG => "G", 76 | Code::KeyH => "H", 77 | Code::KeyI => "I", 78 | Code::KeyJ => "J", 79 | Code::KeyK => "K", 80 | Code::KeyL => "L", 81 | Code::KeyM => "M", 82 | Code::KeyN => "N", 83 | Code::KeyO => "O", 84 | Code::KeyP => "P", 85 | Code::KeyQ => "Q", 86 | Code::KeyR => "R", 87 | Code::KeyS => "S", 88 | Code::KeyT => "T", 89 | Code::KeyU => "U", 90 | Code::KeyV => "V", 91 | Code::KeyW => "W", 92 | Code::KeyX => "X", 93 | Code::KeyY => "Y", 94 | Code::KeyZ => "Z", 95 | Code::Minus => "-", 96 | Code::Period => ".", 97 | Code::Quote => "\"", 98 | Code::Semicolon => ";", 99 | Code::Slash => "/", 100 | Code::Space => " ", 101 | Code::NumpadAdd => "Add", 102 | Code::NumpadClear => "Clear", 103 | Code::NumpadDivide => "Divide", 104 | Code::NumpadSubtract => "Subtract", 105 | Code::LaunchApp1 => "LaunchApplication1", 106 | Code::LaunchApp2 => "LaunchApplication2", 107 | Code::MediaSelect => "LaunchMediaPlayer", 108 | Code::MicrophoneMuteToggle => "MicrophoneToggle", 109 | 110 | //If I don't have a custom mapping because it wasn't found in the list 111 | //of key codes from Electron, or the Electron mapping and the default Code 112 | //mapping are the same just use the default to string for the Global KeyCode 113 | _ => &tmp_str, 114 | } 115 | ) 116 | } 117 | } 118 | 119 | impl std::str::FromStr for GKeyCode { 120 | type Err = &'static str; //Todo: Get a better Error type 121 | 122 | fn from_str(s: &str) -> Result { 123 | use crate::Code; 124 | Ok(match s { 125 | "\\" => Code::Backslash, 126 | "[" => Code::BracketLeft, 127 | "]" => Code::BracketRight, 128 | "," => Code::Comma, 129 | "0" => Code::Digit0, 130 | "1" => Code::Digit1, 131 | "2" => Code::Digit2, 132 | "3" => Code::Digit3, 133 | "4" => Code::Digit4, 134 | "5" => Code::Digit5, 135 | "6" => Code::Digit6, 136 | "7" => Code::Digit7, 137 | "8" => Code::Digit8, 138 | "9" => Code::Digit9, 139 | "=" => Code::Equal, 140 | "A" => Code::KeyA, 141 | "B" => Code::KeyB, 142 | "C" => Code::KeyC, 143 | "D" => Code::KeyD, 144 | "E" => Code::KeyE, 145 | "F" => Code::KeyF, 146 | "G" => Code::KeyG, 147 | "H" => Code::KeyH, 148 | "I" => Code::KeyI, 149 | "J" => Code::KeyJ, 150 | "K" => Code::KeyK, 151 | "L" => Code::KeyL, 152 | "M" => Code::KeyM, 153 | "N" => Code::KeyN, 154 | "O" => Code::KeyO, 155 | "P" => Code::KeyP, 156 | "Q" => Code::KeyQ, 157 | "R" => Code::KeyR, 158 | "S" => Code::KeyS, 159 | "T" => Code::KeyT, 160 | "U" => Code::KeyU, 161 | "V" => Code::KeyV, 162 | "W" => Code::KeyW, 163 | "X" => Code::KeyX, 164 | "Y" => Code::KeyY, 165 | "Z" => Code::KeyZ, 166 | "-" => Code::Minus, 167 | "." => Code::Period, 168 | "\"" => Code::Quote, 169 | ";" => Code::Semicolon, 170 | "/" => Code::Slash, 171 | " " => Code::Space, 172 | "Add" => Code::NumpadAdd, 173 | "Clear" => Code::NumpadClear, 174 | "Divide" => Code::NumpadDivide, 175 | "Subtract" => Code::NumpadSubtract, 176 | "LaunchApplication1" => Code::LaunchApp1, 177 | "LaunchApplication2" => Code::LaunchApp2, 178 | "MicrophoneToggle" => Code::MicrophoneMuteToggle, 179 | 180 | _ => Code::from_str(s).map_err(|_| "Failure to convert Key Code")?, 181 | } 182 | .into()) 183 | } 184 | } 185 | 186 | impl Serialize for GKeyCode { 187 | fn serialize(&self, serializer: S) -> Result 188 | where 189 | S: serde::Serializer, 190 | { 191 | serializer.serialize_str(&self.to_string()) 192 | } 193 | } 194 | 195 | impl<'de> Deserialize<'de> for GKeyCode { 196 | fn deserialize(deserializer: D) -> Result 197 | where 198 | D: serde::Deserializer<'de>, 199 | { 200 | let s = String::deserialize(deserializer)?; 201 | Ok(s.parse().unwrap()) 202 | } 203 | } 204 | 205 | //Right now serde support in Slint is new and crude, some of the types in the Slint version 206 | //of this struct like Brush don't support serde yet. So for now I'm creating 2 versions 207 | //the slint version and this version to manually convert between them. 208 | #[derive(Clone, Deserialize)] 209 | struct ThemeColors { 210 | #[serde(rename = "--color-long-round")] 211 | long_round: HexColor, 212 | 213 | #[serde(rename = "--color-short-round")] 214 | short_round: HexColor, 215 | 216 | #[serde(rename = "--color-focus-round")] 217 | focus_round: HexColor, 218 | 219 | #[serde(rename = "--color-background")] 220 | background: HexColor, 221 | 222 | #[serde(rename = "--color-background-light")] 223 | background_light: HexColor, 224 | 225 | #[serde(rename = "--color-background-lightest")] 226 | background_lightest: HexColor, 227 | 228 | #[serde(rename = "--color-foreground")] 229 | foreground: HexColor, 230 | 231 | #[serde(rename = "--color-foreground-darker")] 232 | foreground_darker: HexColor, 233 | 234 | #[serde(rename = "--color-foreground-darkest")] 235 | foreground_darkest: HexColor, 236 | 237 | #[serde(rename = "--color-accent")] 238 | accent: HexColor, 239 | } 240 | 241 | #[derive(Clone, Deserialize)] 242 | pub struct JsonThemeTemp { 243 | name: String, 244 | colors: ThemeColors, 245 | } 246 | 247 | //I realize implemeting From is more idomatic, but that would require creating a newtype for JsonTheme, 248 | //due to the orphan rule, and then having to convert that (or maybe deref) that into JsonThemeTemp. I think this is a 249 | //good and straight forward stop gap, until slint adds support for Serde to more types 250 | impl Into for JsonThemeTemp { 251 | fn into(self) -> JsonTheme { 252 | JsonTheme { 253 | name: self.name.into(), 254 | long_round: Color::from_rgb_u8( 255 | self.colors.long_round.r, 256 | self.colors.long_round.g, 257 | self.colors.long_round.b, 258 | ) 259 | .into(), 260 | short_round: Color::from_rgb_u8( 261 | self.colors.short_round.r, 262 | self.colors.short_round.g, 263 | self.colors.short_round.b, 264 | ) 265 | .into(), 266 | focus_round: Color::from_rgb_u8( 267 | self.colors.focus_round.r, 268 | self.colors.focus_round.g, 269 | self.colors.focus_round.b, 270 | ) 271 | .into(), 272 | background: Color::from_rgb_u8( 273 | self.colors.background.r, 274 | self.colors.background.g, 275 | self.colors.background.b, 276 | ) 277 | .into(), 278 | background_light: Color::from_rgb_u8( 279 | self.colors.background_light.r, 280 | self.colors.background_light.g, 281 | self.colors.background_light.b, 282 | ) 283 | .into(), 284 | background_lightest: Color::from_rgb_u8( 285 | self.colors.background_lightest.r, 286 | self.colors.background_lightest.g, 287 | self.colors.background_lightest.b, 288 | ) 289 | .into(), 290 | foreground: Color::from_rgb_u8( 291 | self.colors.foreground.r, 292 | self.colors.foreground.g, 293 | self.colors.foreground.b, 294 | ) 295 | .into(), 296 | foreground_darker: Color::from_rgb_u8( 297 | self.colors.foreground_darker.r, 298 | self.colors.foreground_darker.g, 299 | self.colors.foreground_darker.b, 300 | ) 301 | .into(), 302 | foreground_darkest: Color::from_rgb_u8( 303 | self.colors.foreground_darkest.r, 304 | self.colors.foreground_darkest.g, 305 | self.colors.foreground_darkest.b, 306 | ) 307 | .into(), 308 | accent: Color::from_rgb_u8( 309 | self.colors.accent.r, 310 | self.colors.accent.g, 311 | self.colors.accent.b, 312 | ) 313 | .into(), 314 | } 315 | } 316 | } 317 | 318 | #[derive(Debug, Clone, PartialEq)] 319 | pub struct JsonHotKey { 320 | pub modifiers: Modifiers, 321 | pub key: GKeyCode, 322 | } 323 | 324 | impl fmt::Display for JsonHotKey { 325 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 326 | if self.modifiers.ctrl() { 327 | write!(f, "Control+")?; 328 | } 329 | if self.modifiers.alt() { 330 | write!(f, "Alt+")?; 331 | } 332 | if self.modifiers.shift() { 333 | write!(f, "Shift+")?; 334 | } 335 | if self.modifiers.meta() { 336 | write!(f, "Super+")?; 337 | } 338 | 339 | write!(f, "{}", &self.key) 340 | } 341 | } 342 | 343 | impl std::str::FromStr for JsonHotKey { 344 | type Err = &'static str; //Todo: Get a better Error type 345 | 346 | fn from_str(s: &str) -> Result { 347 | let mut mods = Modifiers::empty(); 348 | let mut tokens_it = s.split('+').peekable(); 349 | while let Some(key) = tokens_it.next() { 350 | if tokens_it.peek().is_some() { 351 | match key { 352 | "Control" => mods.set(Modifiers::CONTROL, true), 353 | "Alt" => mods.set(Modifiers::ALT, true), 354 | "Shift" => mods.set(Modifiers::SHIFT, true), 355 | "Super" => mods.set(Modifiers::META, true), 356 | _ => panic!("No Other modifier keys currently supported"), 357 | }; 358 | } else { 359 | return Ok(JsonHotKey { 360 | modifiers: mods, 361 | key: key.parse().unwrap(), 362 | }); 363 | } 364 | } 365 | Err("Something failed") 366 | } 367 | } 368 | 369 | impl Serialize for JsonHotKey { 370 | fn serialize(&self, serializer: S) -> Result 371 | where 372 | S: serde::Serializer, 373 | { 374 | serializer.serialize_str(&self.to_string()) 375 | } 376 | } 377 | 378 | impl<'de> Deserialize<'de> for JsonHotKey { 379 | fn deserialize(deserializer: D) -> Result 380 | where 381 | D: serde::Deserializer<'de>, 382 | { 383 | let s = String::deserialize(deserializer)?; 384 | JsonHotKey::from_str(&s).map_err(serde::de::Error::custom) 385 | } 386 | } 387 | 388 | //I realize implemeting From is more idomatic, but that would require creating a newtype for HotKey, 389 | //due to the orphan rule, and then having to convert that (or maybe deref) that into JsonHotKey. 390 | //I feel like there is a better way to do this...but for now just to get the GlobalHotkeys up and working 391 | //I'll put this in. 392 | impl Into for JsonHotKey { 393 | fn into(self) -> HotKey { 394 | let mods = if self.modifiers.is_empty() { 395 | None 396 | } else { 397 | Modifiers::from_bits(self.modifiers.iter().fold(0, |acc, val| acc | val.bits())) 398 | }; 399 | 400 | HotKey::new(mods, self.key.0) 401 | } 402 | } 403 | 404 | impl Into for &JsonHotKey { 405 | fn into(self) -> HotKey { 406 | let mods = if self.modifiers.is_empty() { 407 | None 408 | } else { 409 | Modifiers::from_bits(self.modifiers.iter().fold(0, |acc, val| acc | val.bits())) 410 | }; 411 | 412 | HotKey::new(mods, self.key.0) 413 | } 414 | } 415 | 416 | //Clippy complains and suggests refactoring so there are fewer bools, but this struct matches pomotroid for 417 | //compaibility so I'm surpressing the warning. Perhaps in v2 I can consider a restructure. 418 | #[allow(clippy::struct_excessive_bools)] 419 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 420 | #[serde(rename_all = "camelCase")] 421 | pub struct JsonSettings { 422 | pub always_on_top: bool, 423 | pub auto_start_break_timer: bool, 424 | pub auto_start_work_timer: bool, 425 | pub break_always_on_top: bool, 426 | pub global_shortcuts: GlobalShortcuts, 427 | pub min_to_tray: bool, 428 | pub min_to_tray_on_close: bool, 429 | pub notifications: bool, 430 | pub theme: String, 431 | pub tick_sounds: bool, 432 | pub tick_sounds_during_break: bool, 433 | pub time_long_break: i32, 434 | pub time_short_break: i32, 435 | pub time_work: i32, 436 | pub volume: i32, 437 | pub work_rounds: i32, 438 | } 439 | 440 | //Need to look into if the serialization of the Slint structs in better in the newer release 441 | //haven't tested in a bit, and I might not need to do this back and forth marshalling to use 442 | //serde on this and the Theme struct anymore.... 443 | /*impl From for Settings { 444 | fn from(other: JsonSettings) -> Self { 445 | Settings { 446 | always_on_top: other.always_on_top, 447 | auto_start_break_timer: other.auto_start_break_timer, 448 | auto_start_work_timer: other.auto_start_work_timer, 449 | break_always_on_top: other.break_always_on_top, 450 | //global_shortcuts: other.global_shortcuts, 451 | min_to_tray: other.min_to_tray, 452 | min_to_tray_on_close: other.min_to_tray_on_close, 453 | notifications: other.notifications, 454 | //theme: other.theme, 455 | tick_sounds: other.tick_sounds, 456 | tick_sounds_during_break: other.tick_sounds_during_break, 457 | time_long_break: other.time_long_break.into(), 458 | time_short_break: other.time_short_break.into(), 459 | time_work: other.time_work.into(), 460 | volume: other.volume.into(), 461 | work_rounds: other.work_rounds.into(), 462 | } 463 | } 464 | }*/ 465 | 466 | //#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 467 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 468 | #[serde(rename_all = "camelCase")] 469 | pub struct GlobalShortcuts { 470 | #[serde(rename = "call-timer-reset")] 471 | pub reset: JsonHotKey, 472 | #[serde(rename = "call-timer-skip")] 473 | pub skip: JsonHotKey, 474 | #[serde(rename = "call-timer-toggle")] 475 | pub toggle: JsonHotKey, 476 | } 477 | 478 | static CFG_DIR: OnceLock> = OnceLock::new(); 479 | static DEF_THEME: OnceLock = OnceLock::new(); 480 | 481 | //I'm not finding a lot of information to determine if I'm running under Wayland or not 482 | //this article seems to offer the most suggestions: https://www.baeldung.com/linux/display-server-xorg-wayland 483 | //This is needed to disable features that currently don't work under Wayland, such as Global Hot Keys 484 | //and Always on Top 485 | #[cfg(unix)] 486 | pub fn is_wayland() -> bool { 487 | match env::var("XDG_SESSION_TYPE") { 488 | Ok(val) => val.contains("wayland"), 489 | Err(_) => match env::var("WAYLAND_DISPLAY") { 490 | Ok(val) => val.contains("wayland"), 491 | Err(_) => false, 492 | }, 493 | } 494 | } 495 | 496 | #[cfg(not(unix))] 497 | pub fn is_wayland() -> bool { 498 | false 499 | } 500 | 501 | fn get_dir() -> Option<&'static Path> { 502 | if let Some(dirs) = CFG_DIR.get_or_init(|| ProjectDirs::from("org", "Vadoola", "Tomotroid")) { 503 | Some(dirs.config_dir()) 504 | } else { 505 | None 506 | } 507 | } 508 | 509 | pub fn default_theme() -> &'static JsonThemeTemp { 510 | DEF_THEME.get_or_init(|| { 511 | let def_theme = r##"{ 512 | "name": "Rangitoto", 513 | "colors": { 514 | "--color-long-round": "#af486d", 515 | "--color-short-round": "#719002", 516 | "--color-focus-round": "#3c73b8", 517 | "--color-background": "#1a191e", 518 | "--color-background-light": "#343132", 519 | "--color-background-lightest": "#837c7e", 520 | "--color-foreground": "#dfdfd7", 521 | "--color-foreground-darker": "#bec0c0", 522 | "--color-foreground-darkest": "#adadae", 523 | "--color-accent": "#cd7a0c" 524 | } 525 | }"##; 526 | serde_json::from_str::(def_theme).unwrap() 527 | }) 528 | } 529 | 530 | //so I'm thinking this module has a load settings and save settings 531 | //it handles getting the proper directory etc. and just reads in a file returning a PathBuf 532 | //need to probably include_bytes or include_str a default settings and default theme. 533 | //so that if no settings and/or no theme files are found it has a fallback 534 | //would it make any sense to use something like Figment(https://crates.io/crates/figment) instead of 535 | //just looking at the raw Json? Could it provide any benefit or flexibility? 536 | //pub fn load_settings() -> Settings { 537 | pub fn load_settings() -> JsonSettings { 538 | //if reading the files fails use default settings 539 | //need to start adding some logging probably 540 | //actually probably need to restructure this a bit 541 | //if the cfg dir doesn't exist, need to load defaults, 542 | //then if reading the file from the dir doesn't exist 543 | //need to load defualts 544 | if let Some(cfg_dir) = get_dir() { 545 | let file = cfg_dir.join("preferences.json"); 546 | if let Ok(set_file) = File::open(file) { 547 | let reader = BufReader::new(set_file); 548 | serde_json::from_reader(reader).expect("To be able to load the settings from json") 549 | } else { 550 | default_settings() 551 | } 552 | } else { 553 | default_settings() 554 | } 555 | } 556 | 557 | pub fn load_themes() -> Vec { 558 | let theme_dir = { 559 | let mut theme_dir = std::path::PathBuf::from(get_dir().unwrap()); 560 | theme_dir.push("themes"); 561 | theme_dir 562 | }; 563 | let mut themes: Vec = WalkDir::new(theme_dir) 564 | .into_iter() 565 | .filter(|e| { 566 | e.as_ref().is_ok_and(|f| { 567 | f.file_name() 568 | .to_str() 569 | .is_some_and(|s| s.to_lowercase().ends_with(".json")) 570 | }) 571 | }) 572 | .filter_map(|e| { 573 | e.map(|e| { 574 | let reader = BufReader::new(File::open(e.path()).unwrap()); 575 | let theme = std::io::read_to_string(reader).unwrap(); 576 | serde_json::from_str::(&theme) 577 | .unwrap() 578 | .into() 579 | }) 580 | .ok() 581 | }) 582 | .collect(); 583 | if themes.is_empty() { 584 | themes.push((*default_theme()).clone().into()); 585 | } 586 | themes.sort_by(|a, b| a.name.partial_cmp(&b.name).unwrap()); 587 | themes 588 | } 589 | 590 | //fn default_settings() -> Settings { 591 | fn default_settings() -> JsonSettings { 592 | let def_set = include_bytes!("../assets/default-preferences.json"); 593 | serde_json::from_reader(&def_set[..]).unwrap() 594 | 595 | //JsonSettings using keycode 596 | /*JsonSettings { 597 | always_on_top: false, 598 | auto_start_break_timer: true, 599 | auto_start_work_timer: true, 600 | break_always_on_top: false, 601 | global_shortcuts: GlobalShortcuts { 602 | call_timer_reset: JsonHotKey { 603 | modifiers: vec![KeyCode::ControlLeft], 604 | key: KeyCode::F2, 605 | }, 606 | call_timer_skip: JsonHotKey { 607 | modifiers: vec![KeyCode::ControlLeft], 608 | key: KeyCode::F3, 609 | }, 610 | call_timer_toggle: JsonHotKey { 611 | modifiers: vec![KeyCode::ControlLeft], 612 | key: KeyCode::F1, 613 | }, 614 | }, 615 | min_to_tray: false, 616 | min_to_tray_on_close: false, 617 | notifications: true, 618 | theme: "Rangitoto".to_string(), 619 | tick_sounds: true, 620 | tick_sounds_during_break: true, 621 | time_long_break: 1, 622 | time_short_break: 1, 623 | time_work: 1, 624 | volume: 100, 625 | work_rounds: 2, 626 | }*/ 627 | 628 | //JsonSettings using Code 629 | /*JsonSettings { 630 | always_on_top: false, 631 | auto_start_break_timer: true, 632 | auto_start_work_timer: true, 633 | break_always_on_top: false, 634 | global_shortcuts: GlobalShortcuts { 635 | call_timer_reset: JsonHotKey { 636 | modifiers: Modifiers::CONTROL, 637 | key: Code::F2.into(), 638 | }, 639 | call_timer_skip: JsonHotKey { 640 | modifiers: Modifiers::CONTROL, 641 | key: Code::F3.into(), 642 | }, 643 | call_timer_toggle: JsonHotKey { 644 | modifiers: Modifiers::CONTROL, 645 | //key: Code::F1, 646 | key: Code::KeyD.into(), 647 | }, 648 | }, 649 | min_to_tray: false, 650 | min_to_tray_on_close: false, 651 | notifications: true, 652 | theme: "Rangitoto".to_string(), 653 | tick_sounds: true, 654 | tick_sounds_during_break: true, 655 | time_long_break: 1, 656 | time_short_break: 1, 657 | time_work: 1, 658 | volume: 100, 659 | work_rounds: 2, 660 | }*/ 661 | } 662 | 663 | //Use https://docs.rs/serde_json/latest/serde_json/fn.to_writer_pretty.html 664 | //for writing out the json when I save the settings 665 | 666 | //what's the best way to call this...calling save settings every time a setting change 667 | //would be the safest from the perspective of ensuring the settings are updated 668 | //but that could be a lot of saving the file over and over. 669 | //for example they change a timer slider form say 5m to 10m 670 | //will the slint slider trigger the callback once, saying it was changed to 10, 671 | //or will it trigger on every value update (ie, 6, 7, 8, 9, 10) triggering 5 updates 672 | //to save settings? Since the settings file is pretty small/simple, I'm not sure it's worth 673 | //trying to update just the value that's changed, probably just easier to rewrite the whole 674 | //file every time. I could just save the settings on program exit...but if the program does crash 675 | //the settings woin't get saved. Is there some good middle ground? Every X minutes check if there 676 | //is a mismatch and save the settings? But then I have the overhead of some sort of timer to 677 | //check every so often....Actually could I save the settings only when on the main screen somehow? 678 | //So if the volume is changed, it saves right away (may not be super effecient, if it triggers for 679 | //update of the slider and they change the volume a large amount), but if it changes something on the 680 | //slidover screen, ie, timer, theme, etc it only saves when the slideover goes away? The logic might be 681 | //a bit trickier, but might be a good middle ground of ensuring the settings get saved without 682 | //writing out the file quite as much. 683 | pub fn save_settings(settings: &JsonSettings) { 684 | if let Some(cfg_dir) = get_dir() { 685 | std::fs::create_dir_all(cfg_dir).unwrap(); 686 | 687 | let file = cfg_dir.join("preferences.json"); 688 | let set_file = OpenOptions::new() 689 | .write(true) 690 | .create(true) 691 | .truncate(true) 692 | .open(file) 693 | .unwrap(); 694 | let writer = BufWriter::new(set_file); 695 | 696 | serde_json::to_writer_pretty(writer, &settings) 697 | .expect("To be able to write the settings back out to json"); 698 | } 699 | } 700 | 701 | pub fn get_non_print_key_txt(text: &SharedString) -> Option<&'static str> { 702 | //the way Slint returns the key pressed is as a SharedString 703 | //For non-printable characters they do some sort of unicode encoding 704 | //and you can compare it against the Key enum, by converting the Key 705 | //enum to a Shared String or a char. It looks like the idea is this is 706 | //to be used when creating a slint platform, and sending key events to the window 707 | //It's not really designed for what I'm using it for it seems. 708 | //I can't convert the shared string from the key event into a Key enum it seems, 709 | //and then match against the Key enum...so that means I need to convert every 710 | //instance of Key enum into a SharedString and then match against that....except I 711 | //can't actually "match" a SharedString against a variable SharedString...so this becomes a 712 | //massive if / else if block.... 713 | //I have to be missing something...there has to be a better way than this massive if/else if block 714 | 715 | if *text == SharedString::from(Key::Backspace) { 716 | Some("Bcksp") 717 | } else if *text == SharedString::from(Key::Tab) { 718 | Some("Tab") 719 | } else if *text == SharedString::from(Key::Return) { 720 | Some("Return") 721 | } else if *text == SharedString::from(Key::Escape) { 722 | Some("Esc") 723 | } else if *text == SharedString::from(Key::Backtab) { 724 | Some("BckTab") 725 | } else if *text == SharedString::from(Key::Delete) { 726 | Some("Del") 727 | } else if *text == SharedString::from(Key::CapsLock) { 728 | Some("CapsLk") 729 | } else if *text == SharedString::from(Key::UpArrow) { 730 | Some("↑") 731 | } else if *text == SharedString::from(Key::DownArrow) { 732 | Some("↓") 733 | } else if *text == SharedString::from(Key::LeftArrow) { 734 | Some("→") 735 | } else if *text == SharedString::from(Key::RightArrow) { 736 | Some("←") 737 | } else if *text == SharedString::from(Key::F1) { 738 | Some("F1") 739 | } else if *text == SharedString::from(Key::F2) { 740 | Some("F2") 741 | } else if *text == SharedString::from(Key::F3) { 742 | Some("F3") 743 | } else if *text == SharedString::from(Key::F4) { 744 | Some("F4") 745 | } else if *text == SharedString::from(Key::F5) { 746 | Some("F5") 747 | } else if *text == SharedString::from(Key::F6) { 748 | Some("F6") 749 | } else if *text == SharedString::from(Key::F7) { 750 | Some("F7") 751 | } else if *text == SharedString::from(Key::F8) { 752 | Some("F8") 753 | } else if *text == SharedString::from(Key::F9) { 754 | Some("F9") 755 | } else if *text == SharedString::from(Key::F10) { 756 | Some("F10") 757 | } else if *text == SharedString::from(Key::F11) { 758 | Some("F11") 759 | } else if *text == SharedString::from(Key::F12) { 760 | Some("F12") 761 | } else if *text == SharedString::from(Key::F13) { 762 | Some("F13") 763 | } else if *text == SharedString::from(Key::F14) { 764 | Some("F14") 765 | } else if *text == SharedString::from(Key::F15) { 766 | Some("F15") 767 | } else if *text == SharedString::from(Key::F16) { 768 | Some("F16") 769 | } else if *text == SharedString::from(Key::F17) { 770 | Some("F17") 771 | } else if *text == SharedString::from(Key::F18) { 772 | Some("F18") 773 | } else if *text == SharedString::from(Key::F19) { 774 | Some("F19") 775 | } else if *text == SharedString::from(Key::F20) { 776 | Some("F20") 777 | } else if *text == SharedString::from(Key::F21) { 778 | Some("F21") 779 | } else if *text == SharedString::from(Key::F22) { 780 | Some("F22") 781 | } else if *text == SharedString::from(Key::F23) { 782 | Some("F23") 783 | } else if *text == SharedString::from(Key::F24) { 784 | Some("F24") 785 | } else if *text == SharedString::from(Key::Insert) { 786 | Some("Ins") 787 | } else if *text == SharedString::from(Key::Home) { 788 | Some("Home") 789 | } else if *text == SharedString::from(Key::End) { 790 | Some("End") 791 | } else if *text == SharedString::from(Key::PageUp) { 792 | Some("PgUp") 793 | } else if *text == SharedString::from(Key::PageDown) { 794 | Some("PgDwn") 795 | } else if *text == SharedString::from(Key::ScrollLock) { 796 | Some("ScrLk") 797 | } else if *text == SharedString::from(Key::Pause) { 798 | Some("Pause") 799 | } else if *text == SharedString::from(Key::SysReq) { 800 | Some("SysReq") 801 | } else if *text == SharedString::from(Key::Stop) { 802 | Some("Stop") 803 | } else if *text == SharedString::from(Key::Menu) { 804 | Some("Menu") 805 | } else { 806 | None 807 | } 808 | } 809 | 810 | pub fn bool_changed( 811 | handle: &Weak
, 812 | model: &Rc>, 813 | set_type: BoolSettTypes, 814 | val: bool, 815 | ) { 816 | let handle = handle.upgrade().unwrap(); 817 | 818 | if let Some(conf_data) = model.row_data(set_type.to_usize()) { 819 | model.set_row_data( 820 | set_type.to_usize(), 821 | ConfigData { 822 | state: !val, 823 | ..conf_data 824 | }, 825 | ); 826 | 827 | if set_type == BoolSettTypes::AlwOnTop { 828 | let conf_modl2 = model.clone(); 829 | let aot_break = conf_modl2 830 | .row_data(BoolSettTypes::BrkAlwOnTop.to_usize()) 831 | .unwrap(); 832 | 833 | if val { 834 | conf_modl2.set_row_data( 835 | BoolSettTypes::BrkAlwOnTop.to_usize(), 836 | ConfigData { 837 | animate_out: true, 838 | ..aot_break 839 | }, 840 | ); 841 | } else { 842 | conf_modl2.set_row_data( 843 | BoolSettTypes::BrkAlwOnTop.to_usize(), 844 | ConfigData { 845 | enabled: !val, 846 | animate_in: true, 847 | ..aot_break 848 | }, 849 | ); 850 | } 851 | 852 | let flt_timer = Timer::default(); 853 | flt_timer.start( 854 | slint::TimerMode::SingleShot, 855 | std::time::Duration::from_millis(350), 856 | move || { 857 | let aot_break = conf_modl2 858 | .row_data(BoolSettTypes::BrkAlwOnTop.to_usize()) 859 | .unwrap(); 860 | 861 | if val { 862 | conf_modl2.set_row_data( 863 | BoolSettTypes::BrkAlwOnTop.to_usize(), 864 | ConfigData { 865 | enabled: !val, 866 | animate_out: false, 867 | ..aot_break 868 | }, 869 | ); 870 | } else { 871 | conf_modl2.set_row_data( 872 | BoolSettTypes::BrkAlwOnTop.to_usize(), 873 | ConfigData { 874 | animate_in: false, 875 | ..aot_break 876 | }, 877 | ); 878 | } 879 | }, 880 | ); 881 | } 882 | 883 | match set_type { 884 | BoolSettTypes::AlwOnTop => { 885 | handle.global::().set_always_on_top(!val); 886 | } 887 | BoolSettTypes::AutoStrtBreakTim => { 888 | handle.global::().set_auto_start_break_timer(!val); 889 | } 890 | BoolSettTypes::AutoStrtWrkTim => { 891 | handle.global::().set_auto_start_work_timer(!val); 892 | } 893 | BoolSettTypes::BrkAlwOnTop => { 894 | handle.global::().set_break_always_on_top(!val); 895 | } 896 | BoolSettTypes::MinToTray => { 897 | handle.global::().set_min_to_tray(!val); 898 | } 899 | BoolSettTypes::MinToTryCls => { 900 | handle.global::().set_min_to_tray_on_close(!val); 901 | } 902 | BoolSettTypes::Notifications => { 903 | handle.global::().set_notifications(!val); 904 | } 905 | BoolSettTypes::TickSounds => { 906 | handle.global::().set_tick_sounds(!val); 907 | } 908 | BoolSettTypes::TickSoundsBreak => { 909 | handle 910 | .global::() 911 | .set_tick_sounds_during_break(!val); 912 | } 913 | } 914 | //write out settings?...not the most effecient way every change..but for now should be fine 915 | handle.save_settings(); 916 | } else { 917 | //error getting row data 918 | } 919 | } 920 | 921 | pub fn int_changed(handle: &Weak
, vol_sink: &Rc, set_type: IntSettTypes, val: i32) { 922 | let handle = handle.upgrade().unwrap(); 923 | match set_type { 924 | IntSettTypes::LongBreak => { 925 | handle.global::().set_time_long_break(val); 926 | } 927 | IntSettTypes::ShortBreak => { 928 | handle.global::().set_time_short_break(val); 929 | } 930 | IntSettTypes::Work => { 931 | handle.global::().set_time_work(val); 932 | } 933 | IntSettTypes::Volume => { 934 | handle.global::().set_volume(val); 935 | vol_sink.set_volume(val as f32 / 100.0); 936 | } 937 | IntSettTypes::Rounds => { 938 | handle.global::().set_work_rounds(val); 939 | } 940 | } 941 | 942 | //write out settings?...not the most effecient way every change..but for now should be fine 943 | handle.save_settings(); 944 | } 945 | 946 | fn color_to_hex_string(color: slint::Color) -> String { 947 | format!( 948 | "#{:02X}{:02X}{:02X}", 949 | color.red(), 950 | color.green(), 951 | color.blue() 952 | ) 953 | } 954 | 955 | pub fn theme_changed(handle: &Weak
, idx: i32, theme: JsonTheme) { 956 | let handle = handle.upgrade().unwrap(); 957 | handle.global::().set_theme(theme.name); 958 | handle.save_settings(); 959 | 960 | handle.set_logo( 961 | slint::Image::load_from_svg_data( 962 | LOGO_BYTES 963 | .replace( 964 | "stroke:#2f384b", 965 | &format!("stroke:{}", color_to_hex_string(theme.background.color())), 966 | ) 967 | .replace( 968 | "fill:#ff4e4d", 969 | &format!("fill:{}", color_to_hex_string(theme.focus_round.color())), 970 | ) 971 | .replace( 972 | "fill:#992e2e", 973 | &format!( 974 | "fill:{}", 975 | color_to_hex_string(theme.focus_round.color().darker(0.4)) 976 | ), 977 | ) 978 | .replace( 979 | "fill:#f6f2eb", 980 | &format!("fill:{}", color_to_hex_string(theme.foreground.color())), 981 | ) 982 | .replace( 983 | "fill:#05ec8c", 984 | &format!("fill:{}", color_to_hex_string(theme.accent.color())), 985 | ) 986 | .as_bytes(), 987 | ) 988 | .unwrap(), 989 | ); 990 | 991 | handle.global::().set_theme_idx(idx); 992 | handle.global::().set_long_round(theme.long_round); 993 | handle.global::().set_short_round(theme.short_round); 994 | handle.global::().set_focus_round(theme.focus_round); 995 | handle.global::().set_background(theme.background); 996 | handle 997 | .global::() 998 | .set_background_light(theme.background_light); 999 | handle 1000 | .global::() 1001 | .set_background_lightest(theme.background_lightest); 1002 | handle.global::().set_foreground(theme.foreground); 1003 | handle 1004 | .global::() 1005 | .set_foreground_darker(theme.foreground_darker); 1006 | handle 1007 | .global::() 1008 | .set_foreground_darkest(theme.foreground_darkest); 1009 | handle.global::().set_accent(theme.accent); 1010 | } 1011 | --------------------------------------------------------------------------------