├── .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 |
--------------------------------------------------------------------------------
/assets/icons/start.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/info.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/assets/icons/pause.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/skip.svg:
--------------------------------------------------------------------------------
1 |
2 |
30 |
--------------------------------------------------------------------------------
/assets/icons/pallette.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/muted.svg:
--------------------------------------------------------------------------------
1 |
2 |
21 |
--------------------------------------------------------------------------------
/assets/icons/mute.svg:
--------------------------------------------------------------------------------
1 |
2 |
22 |
--------------------------------------------------------------------------------
/assets/icons/gear.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
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