diff --git a/.github/images/pomotroid_themes-preview--914x219.png b/.github/images/pomotroid_themes-preview--914x219.png new file mode 100644 index 0000000..a834aca Binary files /dev/null and b/.github/images/pomotroid_themes-preview--914x219.png differ diff --git a/README.md b/README.md index d091549..1229214 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ - [x] **Customize round numbers, focus and break times** - [x] **Auto-start round** (optional) - [x] **Desktop notifications** (optional) +- [x] **Built-in [themes](#-themes)** +- [x] **Custom [themes](#-themes)** - [x] **Color gradiant** depending on the remaining time - [x] **Tray icon** with color gradiant - [x] **Minimize to tray** (optional) @@ -40,6 +42,14 @@ Download the install file for your OS from the latest release on https://github.com/vjousse/pomodorolm/releases/ +# 🎨 Themes + +Pomodorolm provides many themes. It's also theme-able, allowing you to customize its appearance. + +![Screenshots of Pomotroid using various themes](./.github/images/pomotroid_themes-preview--914x219.png) + +Visit the [theme documentation](./docs/themes/themes.md) to view the full list of official themes and for instruction on creating your own. + # 💻 Dev You will need to [install rust](https://www.rust-lang.org/tools/install) first. diff --git a/docs/themes/images/andromeda_01.png b/docs/themes/images/andromeda_01.png new file mode 100644 index 0000000..df84ef8 Binary files /dev/null and b/docs/themes/images/andromeda_01.png differ diff --git a/docs/themes/images/andromeda_02.png b/docs/themes/images/andromeda_02.png new file mode 100644 index 0000000..88ea15a Binary files /dev/null and b/docs/themes/images/andromeda_02.png differ diff --git a/docs/themes/images/ayu_01.png b/docs/themes/images/ayu_01.png new file mode 100644 index 0000000..3e4c810 Binary files /dev/null and b/docs/themes/images/ayu_01.png differ diff --git a/docs/themes/images/ayu_02.png b/docs/themes/images/ayu_02.png new file mode 100644 index 0000000..2044c1f Binary files /dev/null and b/docs/themes/images/ayu_02.png differ diff --git a/docs/themes/images/city-lights_01.png b/docs/themes/images/city-lights_01.png new file mode 100644 index 0000000..f4b249e Binary files /dev/null and b/docs/themes/images/city-lights_01.png differ diff --git a/docs/themes/images/city-lights_02.png b/docs/themes/images/city-lights_02.png new file mode 100644 index 0000000..3fff28b Binary files /dev/null and b/docs/themes/images/city-lights_02.png differ diff --git a/docs/themes/images/dracula_01.png b/docs/themes/images/dracula_01.png new file mode 100644 index 0000000..fc58ec8 Binary files /dev/null and b/docs/themes/images/dracula_01.png differ diff --git a/docs/themes/images/dracula_02.png b/docs/themes/images/dracula_02.png new file mode 100644 index 0000000..2b5cf1a Binary files /dev/null and b/docs/themes/images/dracula_02.png differ diff --git a/docs/themes/images/dva_01.png b/docs/themes/images/dva_01.png new file mode 100644 index 0000000..18eddb2 Binary files /dev/null and b/docs/themes/images/dva_01.png differ diff --git a/docs/themes/images/dva_02.png b/docs/themes/images/dva_02.png new file mode 100644 index 0000000..7cebd1c Binary files /dev/null and b/docs/themes/images/dva_02.png differ diff --git a/docs/themes/images/github_01.png b/docs/themes/images/github_01.png new file mode 100644 index 0000000..d671525 Binary files /dev/null and b/docs/themes/images/github_01.png differ diff --git a/docs/themes/images/github_02.png b/docs/themes/images/github_02.png new file mode 100644 index 0000000..e971da4 Binary files /dev/null and b/docs/themes/images/github_02.png differ diff --git a/docs/themes/images/graphite_01.png b/docs/themes/images/graphite_01.png new file mode 100644 index 0000000..b915ee0 Binary files /dev/null and b/docs/themes/images/graphite_01.png differ diff --git a/docs/themes/images/graphite_02.png b/docs/themes/images/graphite_02.png new file mode 100644 index 0000000..ffd827d Binary files /dev/null and b/docs/themes/images/graphite_02.png differ diff --git a/docs/themes/images/gruvbox_01.png b/docs/themes/images/gruvbox_01.png new file mode 100644 index 0000000..c40da7f Binary files /dev/null and b/docs/themes/images/gruvbox_01.png differ diff --git a/docs/themes/images/gruvbox_02.png b/docs/themes/images/gruvbox_02.png new file mode 100644 index 0000000..bf0d30d Binary files /dev/null and b/docs/themes/images/gruvbox_02.png differ diff --git a/docs/themes/images/monokai_01.png b/docs/themes/images/monokai_01.png new file mode 100644 index 0000000..f7db5ab Binary files /dev/null and b/docs/themes/images/monokai_01.png differ diff --git a/docs/themes/images/monokai_02.png b/docs/themes/images/monokai_02.png new file mode 100644 index 0000000..aad5c73 Binary files /dev/null and b/docs/themes/images/monokai_02.png differ diff --git a/docs/themes/images/nord_01.png b/docs/themes/images/nord_01.png new file mode 100644 index 0000000..f7a05ea Binary files /dev/null and b/docs/themes/images/nord_01.png differ diff --git a/docs/themes/images/nord_02.png b/docs/themes/images/nord_02.png new file mode 100644 index 0000000..65722ac Binary files /dev/null and b/docs/themes/images/nord_02.png differ diff --git a/docs/themes/images/one-dark-pro_01.png b/docs/themes/images/one-dark-pro_01.png new file mode 100644 index 0000000..dfd0652 Binary files /dev/null and b/docs/themes/images/one-dark-pro_01.png differ diff --git a/docs/themes/images/one-dark-pro_02.png b/docs/themes/images/one-dark-pro_02.png new file mode 100644 index 0000000..4065263 Binary files /dev/null and b/docs/themes/images/one-dark-pro_02.png differ diff --git a/docs/themes/images/pomotroid_01.png b/docs/themes/images/pomotroid_01.png new file mode 100644 index 0000000..f2dda37 Binary files /dev/null and b/docs/themes/images/pomotroid_01.png differ diff --git a/docs/themes/images/pomotroid_02.png b/docs/themes/images/pomotroid_02.png new file mode 100644 index 0000000..2632f38 Binary files /dev/null and b/docs/themes/images/pomotroid_02.png differ diff --git a/docs/themes/images/popping-and-locking_01.png b/docs/themes/images/popping-and-locking_01.png new file mode 100644 index 0000000..90a8476 Binary files /dev/null and b/docs/themes/images/popping-and-locking_01.png differ diff --git a/docs/themes/images/popping-and-locking_02.png b/docs/themes/images/popping-and-locking_02.png new file mode 100644 index 0000000..b19ec9a Binary files /dev/null and b/docs/themes/images/popping-and-locking_02.png differ diff --git a/docs/themes/images/solarized-light_01.png b/docs/themes/images/solarized-light_01.png new file mode 100644 index 0000000..6ae433b Binary files /dev/null and b/docs/themes/images/solarized-light_01.png differ diff --git a/docs/themes/images/solarized-light_02.png b/docs/themes/images/solarized-light_02.png new file mode 100644 index 0000000..fe8b678 Binary files /dev/null and b/docs/themes/images/solarized-light_02.png differ diff --git a/docs/themes/images/spandex_01.png b/docs/themes/images/spandex_01.png new file mode 100644 index 0000000..a187739 Binary files /dev/null and b/docs/themes/images/spandex_01.png differ diff --git a/docs/themes/images/spandex_02.png b/docs/themes/images/spandex_02.png new file mode 100644 index 0000000..3bd2635 Binary files /dev/null and b/docs/themes/images/spandex_02.png differ diff --git a/docs/themes/images/synthwave_01.png b/docs/themes/images/synthwave_01.png new file mode 100644 index 0000000..030ab2f Binary files /dev/null and b/docs/themes/images/synthwave_01.png differ diff --git a/docs/themes/images/synthwave_02.png b/docs/themes/images/synthwave_02.png new file mode 100644 index 0000000..4a1f1e6 Binary files /dev/null and b/docs/themes/images/synthwave_02.png differ diff --git a/docs/themes/images/tokyo-night-storm_01.png b/docs/themes/images/tokyo-night-storm_01.png new file mode 100644 index 0000000..b43ecf2 Binary files /dev/null and b/docs/themes/images/tokyo-night-storm_01.png differ diff --git a/docs/themes/images/tokyo-night-storm_02.png b/docs/themes/images/tokyo-night-storm_02.png new file mode 100644 index 0000000..4e73868 Binary files /dev/null and b/docs/themes/images/tokyo-night-storm_02.png differ diff --git a/docs/themes/theme-template.json b/docs/themes/theme-template.json new file mode 100644 index 0000000..c0dbc4a --- /dev/null +++ b/docs/themes/theme-template.json @@ -0,0 +1,17 @@ +{ + "name": "Theme Name", + "colors": { + "--color-long-round": "", + "--color-short-round": "", + "--color-focus-round": "", + "--color-focus-round-middle": "", + "--color-focus-round-end": "", + "--color-background": "", + "--color-background-light": "", + "--color-background-lightest": "", + "--color-foreground": "", + "--color-foreground-darker": "", + "--color-foreground-darkest": "", + "--color-accent": "" + } +} diff --git a/docs/themes/themes.md b/docs/themes/themes.md new file mode 100644 index 0000000..248d646 --- /dev/null +++ b/docs/themes/themes.md @@ -0,0 +1,67 @@ +# Pomotroid Themes + +Pomotroid comes with many officially supported themes. You can also add any number of custom themes. + +- [Pomotroid Themes](#pomotroid-themes) + - [Available Themes](#available-themes) + - [Creating a Custom Theme](#creating-a-custom-theme) + +## Available Themes + +These themes are available by default. + +| Theme | Main App | Timer Colors | +| ------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| Andromeda | ![Andromeda theme preview](images/andromeda_01.png) | ![Andromeda theme preview](images/andromeda_02.png) | +| Ayu Mirage | ![Ayu Mirage theme preview](images/ayu_01.png) | ![Ayu Mirage theme preview](images/ayu_02.png) | +| City Lights | ![City Lights theme preview](images/city-lights_01.png) | ![City Lights theme preview](images/city-lights_02.png) | +| Dracula | ![Dracula theme preview](images/dracula_01.png) | ![Dracula theme preview](images/dracula_02.png) | +| D.Va | ![D.Va theme preview](images/dva_01.png) | ![D.Va theme preview](images/dva_02.png) | +| GitHub | ![GitHub theme preview](images/github_01.png) | ![GitHub theme preview](images/github_02.png) | +| Graphite | ![Graphite theme preview](images/graphite_01.png) | ![Graphite theme preview](images/graphite_02.png) | +| Gruvbox | ![Gruvbox theme preview](images/gruvbox_01.png) | ![Gruvbox theme preview](images/gruvbox_02.png) | +| Monokai | ![Monokai theme preview](images/monokai_01.png) | ![Monokai theme preview](images/monokai_02.png) | +| Nord | ![Nord theme preview](images/nord_01.png) | ![Nord theme preview](images/nord_02.png) | +| One Dark Pro | ![One Dark Pro theme preview](images/one-dark-pro_01.png) | ![One Dark Pro theme preview](images/one-dark-pro_02.png) | +| Pomotroid (default) | ![Pomotroid theme preview](images/pomotroid_01.png) | ![Pomotroid theme preview](images/pomotroid_02.png) | +| Popping and Locking | ![Popping and Locking theme preview](images/popping-and-locking_01.png) | ![Popping and Locking theme preview](images/popping-and-locking_02.png) | +| Solarized Light | ![Solarized Light theme preview](images/solarized-light_01.png) | ![Solarized Light theme preview](images/solarized-light_02.png) | +| Spandex | ![Spandex theme preview](images/spandex_01.png) | ![Spandex theme preview](images/spandex_02.png) | +| Sythwave | ![Sythwave theme preview](images/synthwave_01.png) | ![Sythwave theme preview](images/synthwave_02.png) | +| Tokyo Night Storm | ![Tokyo Night Storm theme preview](images/tokyo-night-storm_01.png) | ![Tokyo Night Storm theme preview](images/tokyo-night-storm_02.png) | + +## Creating a Custom Theme + +Creating custom themes is simple. Themes are defined by a `json` file containing a **theme name** and several color values. Use the [theme template file](./theme-template.json) as a starting point. + +```json +{ + "name": "Theme Name", + "colors": { + "--color-long-round": "", + "--color-short-round": "", + "--color-focus-round": "", + "--color-focus-round-middle": "", + "--color-focus-round-end": "", + "--color-background": "", + "--color-background-light": "", + "--color-background-lightest": "", + "--color-foreground": "", + "--color-foreground-darker": "", + "--color-foreground-darkest": "", + "--color-accent": "" + } +} +``` + +`--color-focus-round-middle` and `--color-focus-round-end` are optional. You can use theme to customize the color of the gradiant during the focus round. If none are provided, a gradiant will be automatically computed from the `--color-focus-round` color to the `--color-short-round` color. + +To add your custom theme, copy your theme definition to the `pomodorolm/themes` directory in the `appData` directory. The location of the `appData` depends on the operating system. + +- `%APPDATA%` on **Windows** +- `$XDG_CONFIG_HOME` or `~/.config` on **Linux** +- `~/Library/Application Support` on **macOS** + +For example, add the theme file to the following directory on Windows: `C:\Users\{User}\AppData\Roaming\pomodorolm\themes` + +Restart the application to see your new theme available as an option. diff --git a/elm.json b/elm.json index 99a1a5d..b23ffe3 100644 --- a/elm.json +++ b/elm.json @@ -6,12 +6,14 @@ "elm-version": "0.19.1", "dependencies": { "direct": { + "NoRedInk/elm-json-decode-pipeline": "1.0.1", "elm/browser": "1.0.2", "elm/core": "1.0.3", "elm/html": "1.0.0", "elm/json": "1.1.3", "elm/svg": "1.0.1", - "elm/time": "1.0.0" + "elm/time": "1.0.0", + "rtfeldman/elm-hex": "1.0.0" }, "indirect": { "elm/url": "1.0.0", diff --git a/index.html b/index.html index 744032d..ce6ae82 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,19 @@ - + diff --git a/main.css b/main.css index ba02b1a..ae616e1 100644 --- a/main.css +++ b/main.css @@ -10,16 +10,6 @@ } :root { - --color-long-round: #0bbddb; - --color-short-round: #05ec8c; - --color-focus-round: #ff4e4d; - --color-background: #2f384b; - --color-background-light: #3d4457; - --color-background-lightest: #858c99; - --color-foreground: #f6f2eb; - --color-foreground-darker: #c0c9da; - --color-foreground-darkest: #dbe1ef; - --color-accent: #05ec8c; font-size: 5vw; } @@ -115,7 +105,7 @@ body { } .title { - color: var(--color-short-round); + color: var(--color-focus-round); font-size: 1rem; font-weight: 200; padding-top: 1vw; @@ -529,6 +519,18 @@ input[type="range"]::-webkit-slider-thumb { height: 100%; } +#theme .setting-wrapper { + align-items: center; + border-left: 3px solid; + border-radius: 0 4px 4px 0; + display: flex; + justify-content: space-between; + margin: 12px 0; + min-height: 48px; + padding: 0 12px; + cursor: pointer; +} + #drawer .container { max-height: calc(100% - 10vw); overflow-y: auto; diff --git a/src-elm/ColorHelper.elm b/src-elm/ColorHelper.elm new file mode 100644 index 0000000..368bdf9 --- /dev/null +++ b/src-elm/ColorHelper.elm @@ -0,0 +1,99 @@ +-- Initial code is courtesy of to https://package.elm-lang.org/packages/juliusl/elm-ui-hexcolor/latest/Element-HexColor + + +module ColorHelper exposing (RGB(..), fromCSSHexToRGB, fromRGBToCSSHex) + +import Bitwise +import Dict exposing (Dict) +import Hex +import List + + +type RGB + = RGB Int Int Int + + +toStringWithZeroPadding : Int -> String +toStringWithZeroPadding num = + let + stringValue = + Hex.toString num + in + if String.length stringValue < 2 then + "0" ++ stringValue + + else + stringValue + + +fromCSSHexToRGB : String -> RGB +fromCSSHexToRGB hexcode = + RGB (getRed hexcode) (getGreen hexcode) (getBlue hexcode) + + +fromRGBToCSSHex : RGB -> String +fromRGBToCSSHex (RGB r g b) = + "#" ++ toStringWithZeroPadding r ++ toStringWithZeroPadding g ++ toStringWithZeroPadding b + + +getRed : String -> Int +getRed hexcode = + fromList (List.take 2 (fromCSSString hexcode)) + + +getGreen : String -> Int +getGreen hexcode = + fromList (List.take 2 (List.drop 2 (fromCSSString hexcode))) + + +getBlue : String -> Int +getBlue hexcode = + fromList (List.take 2 (List.drop 4 (fromCSSString hexcode))) + + +fromCSSString : String -> List Char +fromCSSString hexcode = + List.drop 1 (String.toList hexcode) + + +fromList : List Char -> Int +fromList chars = + List.sum (List.indexedMap (\i v -> Bitwise.shiftLeftBy (i * 4) v) (List.reverse (List.map fromChar chars))) + + +fromChar : Char -> Int +fromChar ch = + case Dict.get ch hexmap of + Just v -> + v + + Nothing -> + 0 + + +hexmap : Dict Char Int +hexmap = + Dict.fromList + [ ( '0', 0 ) + , ( '1', 1 ) + , ( '2', 2 ) + , ( '3', 3 ) + , ( '4', 4 ) + , ( '5', 5 ) + , ( '6', 6 ) + , ( '7', 7 ) + , ( '8', 8 ) + , ( '9', 9 ) + , ( 'A', 10 ) + , ( 'B', 11 ) + , ( 'C', 12 ) + , ( 'D', 13 ) + , ( 'E', 14 ) + , ( 'F', 15 ) + , ( 'a', 10 ) + , ( 'b', 11 ) + , ( 'c', 12 ) + , ( 'd', 13 ) + , ( 'e', 14 ) + , ( 'f', 15 ) + ] diff --git a/src-elm/ListWithCurrent.elm b/src-elm/ListWithCurrent.elm new file mode 100644 index 0000000..8f395da --- /dev/null +++ b/src-elm/ListWithCurrent.elm @@ -0,0 +1,180 @@ +module ListWithCurrent exposing (ListWithCurrent(..), addAfter, addAtEnd, addAtStart, addBefore, fromList, getCurrent, moveBackward, moveForward, setCurrentByPredicate, toList, updateCurrent) + + +type ListWithCurrent a + = EmptyListWithCurrent + | ListWithCurrent (List a) a (List a) + + + +-- Adding Element After the Current Element + + +addAfter : a -> ListWithCurrent a -> ListWithCurrent a +addAfter element listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + ListWithCurrent [] element [] + + ListWithCurrent prev current next -> + ListWithCurrent prev current (element :: next) + + + +-- Adding Element Before the Current Element + + +addBefore : a -> ListWithCurrent a -> ListWithCurrent a +addBefore element listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + ListWithCurrent [] element [] + + ListWithCurrent prev current next -> + ListWithCurrent (element :: prev) current next + + +addAtStart : a -> ListWithCurrent a -> ListWithCurrent a +addAtStart element listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + ListWithCurrent [] element [] + + ListWithCurrent prev current next -> + ListWithCurrent [] element (List.reverse prev ++ (current :: next)) + + +addAtEnd : a -> ListWithCurrent a -> ListWithCurrent a +addAtEnd element listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + ListWithCurrent [] element [] + + ListWithCurrent prev current next -> + ListWithCurrent (List.reverse next ++ (current :: prev)) element [] + + +fromList : List a -> ListWithCurrent a +fromList list = + case list of + [] -> + EmptyListWithCurrent + + x :: xs -> + ListWithCurrent [] x xs + + +toList : ListWithCurrent a -> List a +toList listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + [] + + ListWithCurrent prev current next -> + List.reverse prev ++ (current :: next) + + +getCurrent : ListWithCurrent a -> Maybe a +getCurrent listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + Nothing + + ListWithCurrent _ current _ -> + Just current + + +setCurrentByPredicate : (a -> Bool) -> ListWithCurrent a -> ListWithCurrent a +setCurrentByPredicate predicate listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + EmptyListWithCurrent + + ListWithCurrent prev current next -> + let + -- Combine all elements into a single list + combinedList = + List.reverse prev ++ (current :: next) + + -- Find the index of the element that matches the predicate + matchingIndex = + List.indexedMap + (\i elem -> + if predicate elem then + Just i + + else + Nothing + ) + combinedList + |> List.filterMap identity + |> List.head + + -- Split the combined list at the found index + ( before, after ) = + case matchingIndex of + Just idx -> + ( List.take idx combinedList, List.drop idx combinedList ) + + Nothing -> + ( [], combinedList ) + + -- If no match found, use the original list + -- Update `prev`, `current`, and `next` based on the new position + ( newPrev, newCurrentAndNext ) = + case after of + x :: xs -> + ( List.reverse before, x :: xs ) + + _ -> + ( List.reverse before, after ) + + ( newCurrent, newNext ) = + case newCurrentAndNext of + x :: xs -> + ( x, xs ) + + [] -> + ( current, [] ) + in + ListWithCurrent newPrev newCurrent newNext + + +updateCurrent : (a -> a) -> ListWithCurrent a -> ListWithCurrent a +updateCurrent updateFn listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + EmptyListWithCurrent + + ListWithCurrent prev current next -> + ListWithCurrent prev (updateFn current) next + + +moveBackward : ListWithCurrent a -> ListWithCurrent a +moveBackward listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + EmptyListWithCurrent + + ListWithCurrent [] current next -> + -- If there is no previous element, we cannot move backward + ListWithCurrent [] current next + + ListWithCurrent (prevHead :: prevTail) current next -> + -- Move the current element to the next list, and the previous head becomes the new current + ListWithCurrent prevTail prevHead (current :: next) + + +moveForward : ListWithCurrent a -> ListWithCurrent a +moveForward listWithCurrent = + case listWithCurrent of + EmptyListWithCurrent -> + EmptyListWithCurrent + + ListWithCurrent prev current [] -> + -- If there is no next element, we cannot move forward + ListWithCurrent prev current [] + + ListWithCurrent prev current (nextHead :: nextTail) -> + -- Move the current element to the prev list, and the next head becomes the new current + ListWithCurrent (current :: prev) nextHead nextTail diff --git a/src-elm/Main.elm b/src-elm/Main.elm index 98c9453..fe0d74c 100644 --- a/src-elm/Main.elm +++ b/src-elm/Main.elm @@ -1,13 +1,17 @@ port module Main exposing (..) import Browser +import ColorHelper exposing (RGB(..), fromCSSHexToRGB, fromRGBToCSSHex) import Html exposing (Html, a, div, h1, h2, input, nav, p, section, text) import Html.Attributes exposing (attribute, class, href, id, style, target, title, type_, value) import Html.Events exposing (onClick, onInput, onMouseLeave) -import Json.Decode +import Json.Decode as Decode +import Json.Decode.Pipeline as Pipe import Json.Encode +import ListWithCurrent exposing (ListWithCurrent(..)) import Svg exposing (path, svg) import Svg.Attributes as SvgAttr +import Themes exposing (Theme, ThemeColors, pomodorolmTheme) main : Program Flags Model Msg @@ -27,28 +31,23 @@ type alias Seconds = type alias Model = { appVersion : String , config : Config - , currentColor : Color + , currentColor : RGB , currentRoundNumber : Int , currentSessionType : SessionType , currentState : CurrentState , currentTime : Seconds , drawerOpen : Bool - , endColor : Color - , initialColor : Color - , middleColor : Color , muted : Bool , sessionStatus : SessionStatus , settingTab : SettingTab , strokeDasharray : Float + , theme : Theme + , themes : ListWithCurrent Theme , volume : Float , volumeSliderHidden : Bool } -type alias Color = - { r : Int, g : Int, b : Int } - - type alias Config = { alwaysOnTop : Bool , autoStartBreakTimer : Bool @@ -60,31 +59,62 @@ type alias Config = , minimizeToTrayOnClose : Bool , pomodoroDuration : Seconds , shortBreakDuration : Seconds + , theme : String , tickSoundsDuringBreak : Bool , tickSoundsDuringWork : Bool } -configDecoder : Json.Decode.Decoder Config +themeColorsDecoder : Decode.Decoder ThemeColors +themeColorsDecoder = + Decode.succeed ThemeColors + |> Pipe.required "accent" Decode.string + |> Pipe.required "background" Decode.string + |> Pipe.required "background_light" Decode.string + |> Pipe.required "background_lightest" Decode.string + |> Pipe.required "focus_round" Decode.string + |> Pipe.required "focus_round_end" Decode.string + |> Pipe.required "focus_round_middle" Decode.string + |> Pipe.required "foreground" Decode.string + |> Pipe.required "foreground_darker" Decode.string + |> Pipe.required "foreground_darkest" Decode.string + |> Pipe.required "long_round" Decode.string + |> Pipe.required "short_round" Decode.string + + +themeDecoder : Decode.Decoder Theme +themeDecoder = + Decode.succeed Theme + |> Pipe.required "colors" themeColorsDecoder + |> Pipe.required "name" Decode.string + + +themesDecoder : Decode.Decoder (List Theme) +themesDecoder = + Decode.list themeDecoder + + +configDecoder : Decode.Decoder Config configDecoder = let fieldSet0 = - Json.Decode.map8 Config - (Json.Decode.field "always_on_top" Json.Decode.bool) - (Json.Decode.field "auto_start_break_timer" Json.Decode.bool) - (Json.Decode.field "auto_start_work_timer" Json.Decode.bool) - (Json.Decode.field "desktop_notifications" Json.Decode.bool) - (Json.Decode.field "long_break_duration" Json.Decode.int) - (Json.Decode.field "max_round_number" Json.Decode.int) - (Json.Decode.field "minimize_to_tray" Json.Decode.bool) - (Json.Decode.field "minimize_to_tray_on_close" Json.Decode.bool) + Decode.map8 Config + (Decode.field "always_on_top" Decode.bool) + (Decode.field "auto_start_break_timer" Decode.bool) + (Decode.field "auto_start_work_timer" Decode.bool) + (Decode.field "desktop_notifications" Decode.bool) + (Decode.field "long_break_duration" Decode.int) + (Decode.field "max_round_number" Decode.int) + (Decode.field "minimize_to_tray" Decode.bool) + (Decode.field "minimize_to_tray_on_close" Decode.bool) in - Json.Decode.map5 (<|) + Decode.map6 (<|) fieldSet0 - (Json.Decode.field "pomodoro_duration" Json.Decode.int) - (Json.Decode.field "short_break_duration" Json.Decode.int) - (Json.Decode.field "tick_sounds_during_break" Json.Decode.bool) - (Json.Decode.field "tick_sounds_during_work" Json.Decode.bool) + (Decode.field "pomodoro_duration" Decode.int) + (Decode.field "short_break_duration" Decode.int) + (Decode.field "theme" Decode.string) + (Decode.field "tick_sounds_during_break" Decode.bool) + (Decode.field "tick_sounds_during_work" Decode.bool) encodedConfig : Config -> Json.Encode.Value @@ -106,7 +136,7 @@ encodedConfig config = type alias CurrentState = - { color : Color, percentage : Float, paused : Bool, playTick : Bool } + { color : String, percentage : Float, paused : Bool, playTick : Bool } type SessionType @@ -123,6 +153,7 @@ type SessionStatus type SettingTab = TimerTab + | ThemeTab | SettingsTab | AboutTab @@ -177,6 +208,7 @@ type alias Flags = , minimizeToTrayOnClose : Bool , pomodoroDuration : Seconds , shortBreakDuration : Seconds + , theme : String , tickSoundsDuringWork : Bool , tickSoundsDuringBreak : Bool } @@ -191,36 +223,14 @@ defaults = } -green : Color -green = - { r = 5, g = 236, b = 140 } - - -orange : Color -orange = - { r = 255, g = 127, b = 14 } - - -red : Color -red = - { r = 255, g = 78, b = 77 } - - -blue : Color -blue = - { r = 11, g = 189, b = 219 } - - -pink : Color -pink = - { r = 255, g = 137, b = 167 } - - init : Flags -> ( Model, Cmd Msg ) init flags = let + theme = + pomodorolmTheme + currentState = - { color = green + { color = theme.colors.focusRound , percentage = 1 , paused = False , playTick = False @@ -238,45 +248,48 @@ init flags = , minimizeToTrayOnClose = flags.minimizeToTrayOnClose , pomodoroDuration = flags.pomodoroDuration , shortBreakDuration = flags.shortBreakDuration + , theme = flags.theme , tickSoundsDuringWork = flags.tickSoundsDuringWork , tickSoundsDuringBreak = flags.tickSoundsDuringBreak } - , currentColor = green + , currentColor = fromCSSHexToRGB theme.colors.focusRound , currentRoundNumber = 1 , currentSessionType = Pomodoro , currentState = currentState , currentTime = flags.pomodoroDuration , drawerOpen = False - , endColor = red - , initialColor = green - , middleColor = orange , muted = False , sessionStatus = Stopped , settingTab = TimerTab , strokeDasharray = 691.3321533203125 + , theme = theme + , themes = ListWithCurrent.fromList [ theme ] , volume = 1 , volumeSliderHidden = True } , Cmd.batch [ updateCurrentState currentState , loadRustConfig () + , setThemeColors <| theme.colors ] ) type SettingType = FocusTime - | ShortBreakTime | LongBreakTime | Rounds + | ShortBreakTime type Msg = CloseWindow | ChangeSettingTab SettingTab | ChangeSettingConfig Setting + | ChangeTheme Theme | HideVolumeBar | LoadConfig Config + | LoadThemes (List Theme) | MinimizeWindow | NoOp | Reset @@ -287,8 +300,8 @@ type Msg | ToggleDrawer | ToggleMute | ToggleStatus - | UpdateVolume String | UpdateSetting SettingType String + | UpdateVolume String getNextRoundInfo : Model -> NextRoundInfo @@ -297,8 +310,10 @@ getNextRoundInfo model = getNotification : String -> String -> String -> Seconds -> SessionType -> Notification getNotification title body name duration sessionType = let - color = - computeCurrentColor 1 1 sessionType + ( r, g, b ) = + case computeCurrentColor 1 1 sessionType model.theme of + RGB red_ green_ blue_ -> + ( red_, green_, blue_ ) minutes = (duration |> toFloat) / 60 |> round @@ -317,9 +332,9 @@ getNextRoundInfo model = ++ " " ++ body , name = name - , red = color.r - , green = color.g - , blue = color.b + , red = r + , green = g + , blue = b } in case model.currentSessionType of @@ -359,7 +374,7 @@ getNextRoundInfo model = update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = - case msg of + case Debug.log "MSG" msg of ChangeSettingConfig settingConfig -> let settingsConfig = @@ -405,6 +420,34 @@ update msg model = ChangeSettingTab settingTab -> ( { model | settingTab = settingTab }, Cmd.none ) + ChangeTheme theme -> + let + currentState = + model.currentState + + newState = + { currentState | color = fromRGBToCSSHex <| colorForSessionType model.currentSessionType theme } + + config = + model.config + + newConfig = + { config + | theme = theme.name |> String.toLower + } + in + ( { model + | config = newConfig + , currentState = newState + , theme = theme + } + , Cmd.batch + [ setThemeColors theme.colors + , updateConfig newConfig + , updateCurrentState newState + ] + ) + CloseWindow -> ( model , if model.config.minimizeToTrayOnClose then @@ -418,22 +461,82 @@ update msg model = ( { model | volumeSliderHidden = True }, Cmd.none ) LoadConfig config -> - ( { model - | config = config - , sessionStatus = Stopped - , currentTime = - case model.currentSessionType of - Pomodoro -> - config.pomodoroDuration + let + updatedThemes = + model.themes + |> ListWithCurrent.setCurrentByPredicate (\t -> (t.name |> String.toLower) == config.theme) - ShortBreak -> - config.shortBreakDuration + newThemes = + case ListWithCurrent.getCurrent updatedThemes of + Just theme -> + -- We found a theme with the same name than in the config: everything's fine + if (theme.name |> String.toLower) == (model.config.theme |> String.toLower) then + updatedThemes - LongBreak -> - config.longBreakDuration - } - , Cmd.none - ) + else + -- If we didn't found a corresponding theme name, pomodorolm should be the default theme + updatedThemes |> ListWithCurrent.setCurrentByPredicate (\t -> (t.name |> String.toLower) == "pomodorolm") + + Nothing -> + updatedThemes + + newModel = + { model + | config = config + , sessionStatus = Stopped + , themes = newThemes + , currentTime = + case model.currentSessionType of + Pomodoro -> + config.pomodoroDuration + + ShortBreak -> + config.shortBreakDuration + + LongBreak -> + config.longBreakDuration + } + in + case newThemes |> ListWithCurrent.getCurrent of + Just currentTheme -> + update (ChangeTheme currentTheme) newModel + + _ -> + ( newModel, Cmd.none ) + + LoadThemes themes -> + let + loadedThemes = + themes + |> List.sortBy .name + |> ListWithCurrent.fromList + |> ListWithCurrent.setCurrentByPredicate (\t -> (t.name |> String.toLower) == model.config.theme) + + newThemes = + case Debug.log "Current" (ListWithCurrent.getCurrent loadedThemes) of + Just theme -> + -- We found a theme with the same name than in the config: everything's fine + if (theme.name |> String.toLower) == (model.config.theme |> String.toLower) then + loadedThemes + + else + -- If we didn't found a corresponding theme name, pomodorolm should be the default theme + loadedThemes |> ListWithCurrent.setCurrentByPredicate (\t -> (t.name |> String.toLower) == "pomodorolm") + + Nothing -> + loadedThemes + + newModel = + { model + | themes = newThemes + } + in + case newThemes |> ListWithCurrent.getCurrent of + Just currentTheme -> + update (ChangeTheme currentTheme) newModel + + _ -> + ( newModel, Cmd.none ) MinimizeWindow -> ( model @@ -450,7 +553,7 @@ update msg model = Reset -> let currentState = - { color = colorForSessionType model.currentSessionType + { color = fromRGBToCSSHex <| colorForSessionType model.currentSessionType model.theme , percentage = 100 , paused = if model.sessionStatus == Paused then @@ -534,7 +637,7 @@ update msg model = } currentState = - { color = colorForSessionType nextRoundInfo.nextSessionType + { color = fromRGBToCSSHex <| colorForSessionType nextRoundInfo.nextSessionType model.theme , percentage = 100 , paused = if model.sessionStatus == Paused then @@ -574,7 +677,7 @@ update msg model = getCurrentMaxTime model currentColor = - computeCurrentColor newTime maxTime model.currentSessionType + computeCurrentColor newTime maxTime model.currentSessionType model.theme percent = 1 * toFloat newTime / toFloat maxTime @@ -586,7 +689,7 @@ update msg model = } currentState = - { color = currentColor + { color = fromRGBToCSSHex currentColor , percentage = percent , paused = if model.sessionStatus == Paused then @@ -634,7 +737,7 @@ update msg model = } currentState = - { color = colorForSessionType nextRoundInfo.nextSessionType + { color = fromRGBToCSSHex <| colorForSessionType nextRoundInfo.nextSessionType model.theme , percentage = 100 , paused = if nextModel.sessionStatus == Paused then @@ -757,7 +860,13 @@ update msg model = { model | sessionStatus = Paused } currentState = - { color = computeCurrentColor model.currentTime (getCurrentMaxTime model) model.currentSessionType + { color = + fromRGBToCSSHex <| + computeCurrentColor + model.currentTime + (getCurrentMaxTime model) + model.currentSessionType + model.theme , percentage = 1 * toFloat model.currentTime / toFloat (getCurrentMaxTime model) , paused = True , playTick = shouldPlayTick nextModel @@ -773,7 +882,12 @@ update msg model = { model | sessionStatus = Running } currentState = - { color = computeCurrentColor model.currentTime (getCurrentMaxTime model) model.currentSessionType + { color = + fromRGBToCSSHex <| + computeCurrentColor model.currentTime + (getCurrentMaxTime model) + model.currentSessionType + model.theme , percentage = 1 * toFloat model.currentTime / toFloat (getCurrentMaxTime model) , paused = False , playTick = shouldPlayTick nextModel @@ -825,7 +939,7 @@ update msg model = , updateConfig newConfig ) - ShortBreakTime -> + LongBreakTime -> let newValue = if value > 90 then @@ -835,9 +949,9 @@ update msg model = value * 60 in ( { model - | config = { config | shortBreakDuration = newValue } + | config = { config | longBreakDuration = newValue } , currentTime = - if model.currentSessionType == ShortBreak then + if model.currentSessionType == LongBreak then if newValue == 0 then 60 @@ -850,7 +964,22 @@ update msg model = , Cmd.none ) - LongBreakTime -> + Rounds -> + let + newValue = + if value > 12 then + 12 + + else + value + in + ( { model + | config = { config | maxRoundNumber = newValue } + } + , Cmd.none + ) + + ShortBreakTime -> let newValue = if value > 90 then @@ -860,9 +989,9 @@ update msg model = value * 60 in ( { model - | config = { config | longBreakDuration = newValue } + | config = { config | shortBreakDuration = newValue } , currentTime = - if model.currentSessionType == LongBreak then + if model.currentSessionType == ShortBreak then if newValue == 0 then 60 @@ -875,21 +1004,6 @@ update msg model = , Cmd.none ) - Rounds -> - let - newValue = - if value > 12 then - 12 - - else - value - in - ( { model - | config = { config | maxRoundNumber = newValue } - } - , Cmd.none - ) - UpdateVolume volumeStr -> let newVolume = @@ -937,21 +1051,21 @@ shouldPlayTick model = False -colorForSessionType : SessionType -> Color -colorForSessionType sessionType = +colorForSessionType : SessionType -> Theme -> RGB +colorForSessionType sessionType theme = case sessionType of Pomodoro -> - green + fromCSSHexToRGB <| theme.colors.focusRound ShortBreak -> - pink + fromCSSHexToRGB <| theme.colors.shortRound LongBreak -> - blue + fromCSSHexToRGB <| theme.colors.longRound -computeCurrentColor : Seconds -> Seconds -> SessionType -> Color -computeCurrentColor currentTime maxTime sessionType = +computeCurrentColor : Seconds -> Seconds -> SessionType -> Theme -> RGB +computeCurrentColor currentTime maxTime sessionType theme = let percent = 1 * toFloat currentTime / toFloat maxTime @@ -961,20 +1075,35 @@ computeCurrentColor currentTime maxTime sessionType = in case sessionType of Pomodoro -> + let + ( startRed, startGreen, startBlue ) = + case fromCSSHexToRGB theme.colors.focusRound of + RGB r g b -> + ( r, g, b ) + + ( middleRed, middleGreen, middleBlue ) = + case fromCSSHexToRGB theme.colors.focusRoundMiddle of + RGB r g b -> + ( r, g, b ) + + ( endRed, endGreen, endBlue ) = + case fromCSSHexToRGB theme.colors.focusRoundEnd of + RGB r g b -> + ( r, g, b ) + in if percent > 0.5 then - { r = toFloat orange.r + (relativePercent * toFloat (green.r - orange.r)) |> round - , g = toFloat orange.g + (relativePercent * toFloat (green.g - orange.g)) |> round - , b = toFloat orange.b + (relativePercent * toFloat (green.b - orange.b)) |> round - } + RGB + (toFloat middleRed + (relativePercent * toFloat (startRed - middleRed)) |> round) + (toFloat middleGreen + (relativePercent * toFloat (startGreen - middleGreen)) |> round) + (toFloat middleBlue + (relativePercent * toFloat (startBlue - middleBlue)) |> round) else - { r = toFloat red.r + ((1 + relativePercent) * toFloat (orange.r - red.r)) |> round - , g = toFloat red.g + ((1 + relativePercent) * toFloat (orange.g - red.g)) |> round - , b = toFloat red.b + ((1 + relativePercent) * toFloat (orange.b - red.b)) |> round - } + RGB (toFloat endRed + ((1 + relativePercent) * toFloat (middleRed - endRed)) |> round) + (toFloat endGreen + ((1 + relativePercent) * toFloat (middleGreen - endGreen)) |> round) + (toFloat endBlue + ((1 + relativePercent) * toFloat (middleBlue - endBlue)) |> round) s -> - colorForSessionType s + colorForSessionType s theme secondsToString : Seconds -> String @@ -982,8 +1111,8 @@ secondsToString seconds = (String.padLeft 2 '0' <| String.fromInt (seconds // 60)) ++ ":" ++ (String.padLeft 2 '0' <| String.fromInt (modBy 60 seconds)) -dialView : SessionType -> Seconds -> Seconds -> Float -> Html Msg -dialView sessionType currentTime maxTime maxStrokeDasharray = +dialView : SessionType -> Seconds -> Seconds -> Float -> Theme -> Html Msg +dialView sessionType currentTime maxTime maxStrokeDasharray theme = let percent = 1 * toFloat currentTime / toFloat maxTime @@ -991,11 +1120,11 @@ dialView sessionType currentTime maxTime maxStrokeDasharray = strokeDasharray = maxStrokeDasharray - maxStrokeDasharray * percent - colorToHtmlRgbString c = - "rgb(" ++ String.fromInt c.r ++ ", " ++ String.fromInt c.g ++ ", " ++ String.fromInt c.b ++ ")" + colorToHtmlRgbString (RGB r g b) = + "rgb(" ++ String.fromInt r ++ ", " ++ String.fromInt g ++ ", " ++ String.fromInt b ++ ")" color = - colorToHtmlRgbString <| computeCurrentColor currentTime maxTime sessionType + colorToHtmlRgbString <| computeCurrentColor currentTime maxTime sessionType theme in div [ class "dial-wrapper" ] [ p [ class "dial-time" ] @@ -1256,7 +1385,7 @@ getCurrentMaxTime model = timerView : Model -> Html Msg timerView model = div [ class "timer-wrapper" ] - [ dialView model.currentSessionType model.currentTime (getCurrentMaxTime model) model.strokeDasharray + [ dialView model.currentSessionType model.currentTime (getCurrentMaxTime model) model.strokeDasharray model.theme , playPauseView model.sessionStatus , footerView model ] @@ -1609,12 +1738,56 @@ aboutSettingView appVersion = ] +themeSettingView : Model -> Html Msg +themeSettingView model = + div [ class "container", id "theme" ] + (p [ class "drawer-heading" ] [ text "Themes" ] + :: (model.themes + |> ListWithCurrent.toList + |> List.map + (\t -> + let + name = + t.name + + colors = + t.colors + in + div + [ class "setting-wrapper" + , style "background-color" colors.background + , style "border-color" colors.accent + , onClick <| ChangeTheme t + ] + [ p + [ class "setting-title" + , style "color" colors.foreground + ] + [ text name ] + , if t == model.theme then + svg + [ SvgAttr.viewBox "0 0 24 24" + , SvgAttr.width "5vw" + ] + [ path [ SvgAttr.fill colors.accent, SvgAttr.d "M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" ] [] ] + + else + text "" + ] + ) + ) + ) + + drawerView : Model -> Html Msg drawerView model = div [ id "drawer" ] [ case model.settingTab of + ThemeTab -> + themeSettingView model + TimerTab -> timerSettingView model @@ -1705,6 +1878,40 @@ drawerView model = ] ] ] + , div + [ title "Options" + , class "drawer-menu-wrapper" + , class + (if model.settingTab == ThemeTab then + "is-active" + + else + "" + ) + , onClick <| ChangeSettingTab ThemeTab + ] + [ div + [ class "drawer-menu-button" + ] + [ svg + [ SvgAttr.version "1.2" + , SvgAttr.baseProfile "tiny" + , SvgAttr.id "theme-icon" + , SvgAttr.x "0px" + , SvgAttr.y "0px" + , SvgAttr.viewBox "0 0 19.5 20" + , SvgAttr.width "5vw" + , SvgAttr.xmlSpace "preserve" + , SvgAttr.class "icon" + ] + [ path + [ SvgAttr.fill "var(--color-background-lightest)" + , SvgAttr.d "M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.38-.61-.38-.99 0-.83.67-1.5 1.5-1.5H16c2.76 0 5-2.24 5-5 0-4.42-4.03-8-9-8zm-5.5 9c-.83 0-1.5-.67-1.5-1.5S5.67 9 6.5 9 8 9.67 8 10.5 7.33 12 6.5 12zm3-4C8.67 8 8 7.33 8 6.5S8.67 5 9.5 5s1.5.67 1.5 1.5S10.33 8 9.5 8zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 5 14.5 5s1.5.67 1.5 1.5S15.33 8 14.5 8zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 9 17.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z" + ] + [] + ] + ] + ] , div [ title "About" , class "drawer-menu-wrapper" @@ -1764,9 +1971,9 @@ view model = -- SUBSCRIPTIONS -mapLoadConfig : Json.Decode.Value -> Msg +mapLoadConfig : Decode.Value -> Msg mapLoadConfig modelJson = - case Json.Decode.decodeValue configDecoder modelJson of + case Decode.decodeValue configDecoder modelJson of Ok model -> LoadConfig model @@ -1775,18 +1982,33 @@ mapLoadConfig modelJson = NoOp +mapLoadThemes : Decode.Value -> Msg +mapLoadThemes modelJson = + case Decode.decodeValue themesDecoder modelJson of + Ok themes -> + LoadThemes themes + + Err _ -> + --@FIX: don't fail silently + NoOp + + subscriptions : Model -> Sub Msg subscriptions _ = Sub.batch [ tick Tick , loadConfig mapLoadConfig + , loadThemes mapLoadThemes ] port tick : (String -> msg) -> Sub msg -port loadConfig : (Json.Decode.Value -> msg) -> Sub msg +port loadConfig : (Decode.Value -> msg) -> Sub msg + + +port loadThemes : (Decode.Value -> msg) -> Sub msg @@ -1821,3 +2043,6 @@ port notify : Notification -> Cmd msg port updateConfig : Config -> Cmd msg + + +port setThemeColors : ThemeColors -> Cmd msg diff --git a/src-elm/Themes.elm b/src-elm/Themes.elm new file mode 100644 index 0000000..35234f7 --- /dev/null +++ b/src-elm/Themes.elm @@ -0,0 +1,48 @@ +module Themes exposing (RGBColor(..), Theme, ThemeColors, pomodorolmTheme) + + +type RGBColor + = RGB Int Int Int + | RGBA Int Int Int Float + + +type alias Theme = + { colors : ThemeColors + , name : String + } + + +type alias ThemeColors = + { accent : String + , background : String + , backgroundLight : String + , backgroundLightest : String + , focusRound : String + , focusRoundEnd : String + , focusRoundMiddle : String + , foreground : String + , foregroundDarker : String + , foregroundDarkest : String + , longRound : String + , shortRound : String + } + + +pomodorolmTheme : Theme +pomodorolmTheme = + { colors = + { longRound = "#0bbddb" + , shortRound = "#ff4e4d" + , focusRound = "#05ec8c" + , focusRoundMiddle = "#ff7f0e" + , focusRoundEnd = "#ff4e4d" + , background = "#2f384b" + , backgroundLight = "#3d4457" + , backgroundLightest = "#858c99" + , foreground = "#f6f2eb" + , foregroundDarker = "#c0c9da" + , foregroundDarkest = "#dbe1ef" + , accent = "#05ec8c" + } + , name = "Pomodorolm" + } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3dbcf24..9f93bca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1887,6 +1887,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex_color" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37f101bf4c633f7ca2e4b5e136050314503dd198e78e325ea602c327c484ef0" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "hound" version = "3.5.1" @@ -3333,6 +3342,7 @@ name = "pomodorolm" version = "0.1.0" dependencies = [ "futures", + "hex_color", "image 0.25.2", "rodio", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a38f7c7..2760c5d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ name = "pomodorolm" version = "0.1.0" description = "A Tauri App" -authors = ["you"] +authors = ["Vincent Jousse"] license = "" repository = "" edition = "2021" @@ -27,6 +27,7 @@ tauri-plugin-shell = "2.0.0-beta" tauri-plugin-notification = "2.0.0-beta" tauri-plugin-log = "2.0.0-beta" tokio-stream = "0.1.15" +hex_color = "3.0.0" [features] # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. # If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c9026e7..2521493 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,19 +18,23 @@ use tokio::time; // 1.3.0 // pub struct AppState(Arc>); pub struct MenuState(std::sync::Mutex>); use futures::StreamExt; +use hex_color::HexColor; +use std::path::PathBuf; use tauri::Emitter; use tauri_plugin_notification::{NotificationExt, PermissionState}; use tokio_stream::wrappers::IntervalStream; mod icon; mod sound; +const CONFIG_DIR_NAME: &str = "pomodorolm"; + #[derive(Debug, Serialize, Deserialize, Clone)] struct App { play_tick: bool, config: Config, } -#[derive(Debug, Serialize, Deserialize, Copy, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] struct Config { always_on_top: bool, auto_start_work_timer: bool, @@ -42,10 +46,139 @@ struct Config { minimize_to_tray_on_close: bool, pomodoro_duration: u16, short_break_duration: u16, + #[serde(default = "default_theme")] + theme: String, tick_sounds_during_work: bool, tick_sounds_during_break: bool, } +fn default_theme() -> String { + "pomotroid".to_string() +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Colors { + accent: String, + background: String, + background_light: String, + background_lightest: String, + focus_round: String, + focus_round_middle: String, + focus_round_end: String, + foreground: String, + foreground_darker: String, + foreground_darkest: String, + long_round: String, + short_round: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Theme { + colors: Colors, + name: String, +} + +impl From for Theme { + fn from(json_theme: JsonTheme) -> Self { + let (focus_round_middle, focus_round_end) = match ( + json_theme.colors.focus_round_middle, + json_theme.colors.focus_round_end, + ) { + (Some(middle), Some(end)) => (middle, end), + _ => match ( + HexColor::parse(json_theme.colors.short_round.as_str()), + HexColor::parse(json_theme.colors.focus_round.as_str()), + ) { + // If middle or end are not provided, try to compute the middle color ourself + // It will be the middle gradient between the focus round color and the short round + // color + ( + Ok(HexColor { + r: r1, + g: g1, + b: b1, + a: _a1, + }), + Ok(HexColor { + r: r2, + g: g2, + b: b2, + a: _a2, + }), + ) => { + // Middle of the 2 colors + let t = 0.5; + // Compute the middle gradient color + let r = ((1.0 - t) * r1 as f32 + t * r2 as f32).round() as u8; + let g = ((1.0 - t) * g1 as f32 + t * g2 as f32).round() as u8; + let b = ((1.0 - t) * b1 as f32 + t * b2 as f32).round() as u8; + // RGB to hex + ( + format!("#{:02X}{:02X}{:02X}", r, g, b), + json_theme.colors.short_round.clone(), + ) + } + _ => ( + json_theme.colors.focus_round.clone(), + json_theme.colors.focus_round.clone(), + ), + }, + }; + + Theme { + colors: Colors { + accent: json_theme.colors.accent, + background: json_theme.colors.background, + background_light: json_theme.colors.background_light, + background_lightest: json_theme.colors.background_lightest, + focus_round: json_theme.colors.focus_round, + focus_round_middle, + focus_round_end, + foreground: json_theme.colors.foreground, + foreground_darker: json_theme.colors.foreground_darker, + foreground_darkest: json_theme.colors.foreground_darkest, + long_round: json_theme.colors.long_round, + short_round: json_theme.colors.short_round, + }, + name: json_theme.name, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct JsonColors { + #[serde(rename = "--color-accent")] + accent: String, + #[serde(rename = "--color-background")] + background: String, + #[serde(rename = "--color-background-light")] + background_light: String, + #[serde(rename = "--color-background-lightest")] + background_lightest: String, + #[serde(rename = "--color-focus-round")] + focus_round: String, + #[serde(rename = "--color-focus-round-middle")] + focus_round_middle: Option, + #[serde(rename = "--color-focus-round-end")] + focus_round_end: Option, + #[serde(rename = "--color-foreground")] + foreground: String, + #[serde(rename = "--color-foreground-darker")] + foreground_darker: String, + #[serde(rename = "--color-foreground-darkest")] + foreground_darkest: String, + #[serde(rename = "--color-long-round")] + long_round: String, + #[serde(rename = "--color-short-round")] + short_round: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct JsonTheme { + colors: JsonColors, + name: String, +} + #[derive(Debug, Deserialize)] struct ElmNotification { body: String, @@ -68,6 +201,7 @@ impl Default for Config { minimize_to_tray_on_close: true, pomodoro_duration: 25 * 60, short_break_duration: 5 * 60, + theme: "pomotroid".to_string(), tick_sounds_during_work: true, tick_sounds_during_break: true, } @@ -79,6 +213,30 @@ pub fn run() { run_app(tauri::Builder::default()) } +fn get_config_file_path( + path: &tauri::path::PathResolver, +) -> Result { + path.resolve( + format!("{}/config.toml", CONFIG_DIR_NAME), + BaseDirectory::Config, + ) +} + +fn get_config_dir( + path: &tauri::path::PathResolver, +) -> Result { + path.resolve(format!("{}/", CONFIG_DIR_NAME), BaseDirectory::Config) +} + +fn get_config_theme_dir( + path: &tauri::path::PathResolver, +) -> Result { + path.resolve( + format!("{}/themes/", CONFIG_DIR_NAME), + BaseDirectory::Config, + ) +} + pub fn run_app(_builder: tauri::Builder) { tauri::Builder::default() .plugin(tauri_plugin_notification::init()) @@ -139,16 +297,15 @@ pub fn run_app(_builder: tauri::Builder) { }) .build(app); - let config_file_path = &app - .path() - .resolve("config.toml", BaseDirectory::AppConfig)?; + let config_file_path = get_config_file_path(app.path())?; - let metadata = fs::metadata(config_file_path); + let metadata = fs::metadata(&config_file_path); + let _ = fs::create_dir_all(get_config_theme_dir(app.path())?); let config = if metadata.is_err() { // Be sure to create the directory if it doesn't exist. It seems that on Mac, the // Application Support/pomodorolm directory has to be created by hand - fs::create_dir_all(app.path().app_config_dir()?)?; + let _ = fs::create_dir_all(get_config_dir(app.path())?); let mut file = OpenOptions::new() .read(true) @@ -208,6 +365,39 @@ pub fn run_app(_builder: tauri::Builder) { .expect("error while running tauri application"); } +fn load_themes(app_handle: AppHandle) { + let mut themes_paths: Vec = vec![]; + let mut themes: Vec = vec![]; + let themes_path = app_handle + .path() + .resolve("themes/", BaseDirectory::Resource) + .unwrap(); + let paths = fs::read_dir(themes_path).unwrap(); + + for path in paths { + let path_buf = path.unwrap().path(); + themes_paths.push(path_buf); + } + + let config_themes_path = get_config_theme_dir(app_handle.path()).unwrap(); + let paths = fs::read_dir(config_themes_path).unwrap(); + + for path in paths { + let path_buf = path.unwrap().path(); + themes_paths.push(path_buf); + } + for path in themes_paths { + let file = fs::File::open(path.clone()).expect("file should open read only"); + let loaded_theme: Result = serde_json::from_reader(file); + + match loaded_theme { + Ok(theme) => themes.push(Theme::from(theme)), + Err(err) => eprintln!("Impossible to read JSON {}: {:?}", path.display(), err), + } + } + let _ = app_handle.emit("themes", &themes).unwrap(); +} + async fn tick(app_handle: AppHandle, path: String) { let mut stream = IntervalStream::new(time::interval(Duration::from_secs(1))); @@ -270,7 +460,7 @@ async fn update_play_tick(state: tauri::State<'_, AppState>, play_tick: bool) -> *state_guard = App { play_tick, - config: state_guard.config, + config: state_guard.config.clone(), }; Ok(()) @@ -286,11 +476,13 @@ async fn update_config( *state_guard = App { play_tick: state_guard.play_tick, - config, + config: config.clone(), }; - let config_dir = app_handle.path().app_config_dir().unwrap(); - let config_file_path = &format!("{}/config.toml", config_dir.to_string_lossy()); + let config_file_path = get_config_file_path(app_handle.path()) + .unwrap() + .to_string_lossy() + .to_string(); let file = OpenOptions::new() .read(true) @@ -317,17 +509,21 @@ async fn load_config( ) -> Result { let mut state_guard = state.0.lock().await; - let config_dir = app_handle.path().app_config_dir().unwrap(); + let config_file_path = get_config_file_path(app_handle.path()) + .unwrap() + .to_string_lossy() + .to_string(); - let config_file_path = &format!("{}/config.toml", config_dir.to_string_lossy()); - let toml_str = fs::read_to_string(config_file_path) - .expect(&format!("Unable to open config file {}", config_file_path)[..]); + let toml_str = fs::read_to_string(&config_file_path) + .expect(&format!("Unable to open config file {}", config_file_path)); let config: Config = toml::from_str(toml_str.as_str()).expect("Unable to parse config file"); *state_guard = App { play_tick: state_guard.play_tick, - config, + config: config.clone(), }; + let _ = load_themes(app_handle.clone()); + Ok(config) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 20adcde..198cb37 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,7 +32,7 @@ "providerShortName": null, "signingIdentity": null }, - "resources": ["audio/*"], + "resources": ["audio/*", "themes/*"], "shortDescription": "", "linux": { "deb": { diff --git a/src-tauri/themes/andromeda.json b/src-tauri/themes/andromeda.json new file mode 100644 index 0000000..40bd3c0 --- /dev/null +++ b/src-tauri/themes/andromeda.json @@ -0,0 +1,15 @@ +{ + "name": "Andromeda", + "colors": { + "--color-long-round": "#C74DED", + "--color-short-round": "#00E8C6", + "--color-focus-round": "#EE5D43", + "--color-background": "#23262E", + "--color-background-light": "#2e323d", + "--color-background-lightest": "#746f77", + "--color-foreground": "#d5ced9", + "--color-foreground-darker": "#746f77", + "--color-foreground-darkest": "#CD9731", + "--color-accent": "#FFE66D" + } +} diff --git a/src-tauri/themes/ayu.json b/src-tauri/themes/ayu.json new file mode 100644 index 0000000..08e8566 --- /dev/null +++ b/src-tauri/themes/ayu.json @@ -0,0 +1,15 @@ +{ + "name": "Ayu", + "colors": { + "--color-long-round": "#5CCFE6", + "--color-short-round": "#BAE67E", + "--color-focus-round": "#F28779", + "--color-background": "#1F2430", + "--color-background-light": "#2a3546", + "--color-background-lightest": "#707a8c", + "--color-foreground": "#CBCCC6", + "--color-foreground-darker": "#CBCCC6", + "--color-foreground-darkest": "#5ccfe6", + "--color-accent": "#FFCC66" + } +} diff --git a/src-tauri/themes/city-lights.json b/src-tauri/themes/city-lights.json new file mode 100644 index 0000000..1377d5f --- /dev/null +++ b/src-tauri/themes/city-lights.json @@ -0,0 +1,15 @@ +{ + "name": "City Lights", + "colors": { + "--color-long-round": "#6796E6", + "--color-short-round": "#33CED8", + "--color-focus-round": "#E27E8D", + "--color-background": "#1d252c", + "--color-background-light": "#28313a", + "--color-background-lightest": "#718CA1", + "--color-foreground": "#b7c5d3", + "--color-foreground-darker": "#718CA1", + "--color-foreground-darkest": "#718CA1", + "--color-accent": "#EBBF83" + } +} diff --git a/src-tauri/themes/dracula.json b/src-tauri/themes/dracula.json new file mode 100644 index 0000000..67a29d1 --- /dev/null +++ b/src-tauri/themes/dracula.json @@ -0,0 +1,15 @@ +{ + "name": "Dracula", + "colors": { + "--color-long-round": "#8be9fd", + "--color-short-round": "#50fa7b", + "--color-focus-round": "#ff5555", + "--color-background": "#282a36", + "--color-background-light": "#363846", + "--color-background-lightest": "#6272a4", + "--color-foreground": "#f8f8f2", + "--color-foreground-darker": "#ffb86c", + "--color-foreground-darkest": "#ff79c6", + "--color-accent": "#bd93f9" + } +} diff --git a/src-tauri/themes/dva.json b/src-tauri/themes/dva.json new file mode 100644 index 0000000..b2f80c7 --- /dev/null +++ b/src-tauri/themes/dva.json @@ -0,0 +1,15 @@ +{ + "name": "D.Va", + "colors": { + "--color-long-round": "#26adff", + "--color-short-round": "#0de2c9", + "--color-focus-round": "#ec57fd", + "--color-background": "#2e2733", + "--color-background-light": "#35303a", + "--color-background-lightest": "#aba3b3", + "--color-foreground": "#f2f8f7", + "--color-foreground-darker": "#e8d3ea", + "--color-foreground-darkest": "#d5bbd8", + "--color-accent": "#0de2c9" + } +} diff --git a/src-tauri/themes/github.json b/src-tauri/themes/github.json new file mode 100644 index 0000000..4a3aff0 --- /dev/null +++ b/src-tauri/themes/github.json @@ -0,0 +1,15 @@ +{ + "name": "GitHub", + "colors": { + "--color-long-round": "#6F42C1", + "--color-short-round": "#005CC5", + "--color-focus-round": "#CD3131", + "--color-background": "#FFFFFF", + "--color-background-light": "#f6f8fa", + "--color-background-lightest": "#24292e", + "--color-foreground": "#24292e", + "--color-foreground-darker": "#586069", + "--color-foreground-darkest": "#80878e", + "--color-accent": "#005CC5" + } +} diff --git a/src-tauri/themes/graphite.json b/src-tauri/themes/graphite.json new file mode 100644 index 0000000..9de21f1 --- /dev/null +++ b/src-tauri/themes/graphite.json @@ -0,0 +1,15 @@ +{ + "name": "Graphite", + "colors": { + "--color-long-round": "#505154", + "--color-short-round": "#505154", + "--color-focus-round": "#505154", + "--color-background": "#ebebea", + "--color-background-light": "#fcfcfc", + "--color-background-lightest": "#adafb1", + "--color-foreground": "#27292d", + "--color-foreground-darker": "#4a4e56", + "--color-foreground-darkest": "#656a75", + "--color-accent": "#08568c" + } +} diff --git a/src-tauri/themes/gruvbox.json b/src-tauri/themes/gruvbox.json new file mode 100644 index 0000000..d1116c5 --- /dev/null +++ b/src-tauri/themes/gruvbox.json @@ -0,0 +1,15 @@ +{ + "name": "Gruvbox", + "colors": { + "--color-long-round": "#83A598", + "--color-short-round": "#B8BB26", + "--color-focus-round": "#FB4934", + "--color-background": "#282828", + "--color-background-light": "#3c3836", + "--color-background-lightest": "#bdae93", + "--color-foreground": "#ebdbb2", + "--color-foreground-darker": "#bdae93", + "--color-foreground-darkest": "#928374", + "--color-accent": "#FABD2F" + } +} \ No newline at end of file diff --git a/src-tauri/themes/monokai.json b/src-tauri/themes/monokai.json new file mode 100644 index 0000000..ee97e16 --- /dev/null +++ b/src-tauri/themes/monokai.json @@ -0,0 +1,15 @@ +{ + "name": "Monokai", + "colors": { + "--color-long-round": "#66d9ef", + "--color-short-round": "#a6e22e", + "--color-focus-round": "#f92672", + "--color-background": "#272822", + "--color-background-light": "#393a34", + "--color-background-lightest": "#9c9e92", + "--color-foreground": "#FDF9F3", + "--color-foreground-darker": "#dad2c6", + "--color-foreground-darkest": "#d8cbb6", + "--color-accent": "#AE81FF" + } +} diff --git a/src-tauri/themes/nord.json b/src-tauri/themes/nord.json new file mode 100644 index 0000000..2832c17 --- /dev/null +++ b/src-tauri/themes/nord.json @@ -0,0 +1,15 @@ +{ + "name": "Nord", + "colors": { + "--color-long-round": "#5E81AC", + "--color-short-round": "#8FBCBB", + "--color-focus-round": "#B48EAD", + "--color-background": "#2e3440", + "--color-background-light": "#3b4252", + "--color-background-lightest": "#616E88", + "--color-foreground": "#d8dee9", + "--color-foreground-darker": "#8FBCBB", + "--color-foreground-darkest": "#88C0D0", + "--color-accent": "#A3BE8C" + } +} diff --git a/src-tauri/themes/one-dark.json b/src-tauri/themes/one-dark.json new file mode 100644 index 0000000..3e7d807 --- /dev/null +++ b/src-tauri/themes/one-dark.json @@ -0,0 +1,15 @@ +{ + "name": "One Dark Pro", + "colors": { + "--color-long-round": "#61AFEF", + "--color-short-round": "#98C379", + "--color-focus-round": "#E06C75", + "--color-background": "#282c34", + "--color-background-light": "#3b4048", + "--color-background-lightest": "#7f848e", + "--color-foreground": "#abb2bf", + "--color-foreground-darker": "#abb2bf", + "--color-foreground-darkest": "#E5C07B", + "--color-accent": "#C678DD" + } +} diff --git a/src-tauri/themes/pomodorolm.json b/src-tauri/themes/pomodorolm.json new file mode 100644 index 0000000..2508009 --- /dev/null +++ b/src-tauri/themes/pomodorolm.json @@ -0,0 +1,17 @@ +{ + "name": "Pomodorolm", + "colors": { + "--color-long-round": "#0bbddb", + "--color-short-round": "#ff4e4d", + "--color-focus-round": "#05ec8c", + "--color-focus-round-middle": "#ff7f0e", + "--color-focus-round-end": "#ff4e4d", + "--color-background": "#2f384b", + "--color-background-light": "#3d4457", + "--color-background-lightest": "#9ca5b5", + "--color-foreground": "#f6f2eb", + "--color-foreground-darker": "#c0c9da", + "--color-foreground-darkest": "#dbe1ef", + "--color-accent": "#05ec8c" + } +} diff --git a/src-tauri/themes/popping-and-locking.json b/src-tauri/themes/popping-and-locking.json new file mode 100644 index 0000000..69f1ac1 --- /dev/null +++ b/src-tauri/themes/popping-and-locking.json @@ -0,0 +1,15 @@ +{ + "name": "Popping and Locking", + "colors": { + "--color-long-round": "#458588", + "--color-short-round": "#7ec16e", + "--color-focus-round": "#f42c3e", + "--color-background": "#21222d", + "--color-background-light": "#313242", + "--color-background-lightest": "#7f7d7a", + "--color-foreground": "#f2e5bc", + "--color-foreground-darker": "#f9f5d7", + "--color-foreground-darkest": "#ebdbb2", + "--color-accent": "#d79921" + } +} diff --git a/src-tauri/themes/solarized-light.json b/src-tauri/themes/solarized-light.json new file mode 100644 index 0000000..cf5820c --- /dev/null +++ b/src-tauri/themes/solarized-light.json @@ -0,0 +1,15 @@ +{ + "name": "Solarized Light", + "colors": { + "--color-long-round": "#2AA198", + "--color-short-round": "#859900", + "--color-focus-round": "#B58900", + "--color-background": "#FDF6E3", + "--color-background-light": "#EEE8D5", + "--color-background-lightest": "#657b83", + "--color-foreground": "#586e75", + "--color-foreground-darker": "#93A1A1", + "--color-foreground-darkest": "#AC9D57", + "--color-accent": "#268BD2" + } +} diff --git a/src-tauri/themes/spandex.json b/src-tauri/themes/spandex.json new file mode 100644 index 0000000..4db2bc1 --- /dev/null +++ b/src-tauri/themes/spandex.json @@ -0,0 +1,15 @@ +{ + "name": "Spandex", + "colors": { + "--color-long-round": "#00dcff", + "--color-short-round": "#09ffbb", + "--color-focus-round": "#c92fdc", + "--color-background": "#181a1b", + "--color-background-light": "#212425", + "--color-background-lightest": "#5e696d", + "--color-foreground": "#e0e3e6", + "--color-foreground-darker": "#b9bdc1", + "--color-foreground-darkest": "#9da2a7", + "--color-accent": "#f0ff09" + } +} diff --git a/src-tauri/themes/synthwave.json b/src-tauri/themes/synthwave.json new file mode 100644 index 0000000..6986196 --- /dev/null +++ b/src-tauri/themes/synthwave.json @@ -0,0 +1,15 @@ +{ + "name": "Synthwave", + "colors": { + "--color-long-round": "#36F9F6", + "--color-short-round": "#72F1B8", + "--color-focus-round": "#FF7EDB", + "--color-background": "#262335", + "--color-background-light": "#372d4b", + "--color-background-lightest": "#495495", + "--color-foreground": "#e0e3e6", + "--color-foreground-darker": "#b893ce", + "--color-foreground-darkest": "#DD5500", + "--color-accent": "#CD9731" + } +} diff --git a/src-tauri/themes/tokyo-night.json b/src-tauri/themes/tokyo-night.json new file mode 100644 index 0000000..0664da2 --- /dev/null +++ b/src-tauri/themes/tokyo-night.json @@ -0,0 +1,15 @@ +{ + "name": "Tokyo Night Storm", + "colors": { + "--color-long-round": "#7AA2F7", + "--color-short-round": "#73DACA", + "--color-focus-round": "#F7768E", + "--color-background": "#24283b", + "--color-background-light": "#1b1e2e", + "--color-background-lightest": "#9AA5CE", + "--color-foreground": "#c0caf5", + "--color-foreground-darker": "#9AA5CE", + "--color-foreground-darkest": "#89DDFF", + "--color-accent": "#9D7CD8" + } +} diff --git a/src-ts/main.ts b/src-ts/main.ts index 731be7c..5d55a75 100644 --- a/src-ts/main.ts +++ b/src-ts/main.ts @@ -11,14 +11,14 @@ import { getVersion } from "@tauri-apps/api/app"; // Display logs in the webview inspector attachConsole(); -type Color = { - r: number; - g: number; - b: number; -}; +declare global { + interface Window { + __TAURI_INTERNALS__: any; + } +} type ElmState = { - color: Color; + color: string; percentage: number; paused: boolean; playTick: boolean; @@ -33,6 +33,21 @@ type Notification = { blue: number; }; +type ThemeColors = { + longRound: string; + shortRound: string; + focusRound: string; + focusRoundMiddle: string; + focusRoundEnd: string; + background: string; + backgroundLight: string; + backgroundLightest: string; + foreground: string; + foregroundDarker: string; + foregroundDarkest: string; + accent: string; +}; + type ElmConfig = { alwaysOnTop: boolean; autoStartWorkTimer: boolean; @@ -44,6 +59,7 @@ type ElmConfig = { minimizeToTrayOnClose: boolean; pomodoroDuration: number; shortBreakDuration: number; + theme: string; tickSoundsDuringWork: boolean; tickSoundsDuringBreak: boolean; }; @@ -59,6 +75,7 @@ type RustConfig = { minimize_to_tray_on_close: boolean; pomodoro_duration: number; short_break_duration: number; + theme: string; tick_sounds_during_work: boolean; tick_sounds_during_break: boolean; }; @@ -76,6 +93,7 @@ let rustConfig: RustConfig = { minimize_to_tray_on_close: true, pomodoro_duration: 1500, short_break_duration: 300, + theme: "pomodorolm", tick_sounds_during_work: true, tick_sounds_during_break: true, }; @@ -84,7 +102,7 @@ const app = Elm.Main.init({ node: root, flags: { alwaysOnTop: rustConfig.always_on_top, - appVersion: await getVersion(), + appVersion: await getAppVersion(), autoStartWorkTimer: rustConfig.auto_start_work_timer, autoStartBreakTimer: rustConfig.auto_start_break_timer, desktopNotifications: rustConfig.desktop_notifications, @@ -94,6 +112,7 @@ const app = Elm.Main.init({ minimizeToTrayOnClose: rustConfig.minimize_to_tray_on_close, pomodoroDuration: rustConfig.pomodoro_duration, shortBreakDuration: rustConfig.short_break_duration, + theme: rustConfig.theme, tickSoundsDuringWork: rustConfig.tick_sounds_during_work, tickSoundsDuringBreak: rustConfig.tick_sounds_during_break, }, @@ -139,6 +158,7 @@ app.ports.updateConfig.subscribe(function (config: ElmConfig) { minimize_to_tray_on_close: config.minimizeToTrayOnClose, pomodoro_duration: config.pomodoroDuration, short_break_duration: config.shortBreakDuration, + theme: config.theme, tick_sounds_during_work: config.tickSoundsDuringWork, tick_sounds_during_break: config.tickSoundsDuringBreak, }, @@ -148,14 +168,78 @@ app.ports.updateConfig.subscribe(function (config: ElmConfig) { app.ports.updateCurrentState.subscribe(function (state: ElmState) { invoke("update_play_tick", { playTick: state.playTick }); invoke("change_icon", { - red: state.color.r, - green: state.color.g, - blue: state.color.b, + red: hexToRgb(state.color)?.r, + green: hexToRgb(state.color)?.g, + blue: hexToRgb(state.color)?.b, fillPercentage: state.percentage, paused: state.paused, }); }); +app.ports.setThemeColors.subscribe(function (themeColors: ThemeColors) { + let mainHtmlElement = document.documentElement; + mainHtmlElement.style.setProperty( + "--color-long-round", + themeColors.longRound + ); + mainHtmlElement.style.setProperty( + "--color-short-round", + themeColors.shortRound + ); + mainHtmlElement.style.setProperty( + "--color-focus-round", + themeColors.focusRound + ); + mainHtmlElement.style.setProperty( + "--color-background", + themeColors.background + ); + mainHtmlElement.style.setProperty( + "--color-background-light", + themeColors.backgroundLight + ); + mainHtmlElement.style.setProperty( + "--color-background-lightest", + themeColors.backgroundLightest + ); + mainHtmlElement.style.setProperty( + "--color-foreground", + themeColors.foreground + ); + mainHtmlElement.style.setProperty( + "--color-foreground-darker", + themeColors.foregroundDarker + ); + mainHtmlElement.style.setProperty( + "--color-foreground-darkest", + themeColors.foregroundDarkest + ); + mainHtmlElement.style.setProperty("--color-accent", themeColors.accent); +}); + await listen("tick-event", () => { app.ports.tick.send(""); }); + +await listen("themes", (themesEvent) => { + app.ports.loadThemes.send(themesEvent.payload); +}); + +function hexToRgb(hex: string) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +} + +async function getAppVersion() { + if (window.__TAURI_INTERNALS__ === undefined) { + return "unknown"; + } else { + return await getVersion(); + } +}