commit 8585f5741f931e95ef4ebe90eb548684a81e370b Author: Vyn Date: Wed Oct 16 12:17:51 2024 +0200 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbd8eb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +# clangd cache +.cache + +# CMake build directory +build + +.slint.lua +.nvimrc diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a3ddedc --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/selenite"] + path = external/selenite + url = https://codeberg.org/vyn/selenite.git diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d1dff4b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.21) +project(focus LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(SLINT_FEATURE_RENDERER_SKIA ON) +set(SLINT_FEATURE_RENDERER_SOFTWARE ON) + +find_package(Slint QUIET) +if (NOT Slint_FOUND) + message("Slint could not be located in the CMake module search path. Downloading it from Git and building it locally") + include(FetchContent) + FetchContent_Declare( + Slint + GIT_REPOSITORY https://github.com/slint-ui/slint.git + # `release/1` will auto-upgrade to the latest Slint >= 1.0.0 and < 2.0.0 + # `release/1.0` will auto-upgrade to the latest Slint >= 1.0.0 and < 1.1.0 + GIT_TAG release/1.8 + SOURCE_SUBDIR api/cpp + ) + FetchContent_MakeAvailable(Slint) +endif (NOT Slint_FOUND) + +add_executable(focus src/main.cpp) +target_link_libraries(focus PRIVATE Slint::Slint) +slint_target_sources( + focus ui/app-window.slint + NAMESPACE ui + LIBRARY_PATHS selenite=${CMAKE_CURRENT_SOURCE_DIR}/external/selenite/index.slint +) + +# On Windows, copy the Slint DLL next to the application binary so that it's found. +if (WIN32) + add_custom_command(TARGET focus POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ $ COMMAND_EXPAND_LISTS) +endif() diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..242da62 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..17d5f24 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +> [!warning] +> This is a work in progress and not stable. + +# Focus + +Simple Pomodoro timer. + +![Focus](https://codeberg.org/vyn/focus/raw/branch/main/images/presentation-1.png) + +## Build from source + +### Requirements + +- GCC >= 14.2.1 +- CMake >= 3.30.2 +- Ninja (or Make but you will need to adapt the commands) +- Git + +### Steps + +Fetch and setup the repository: +``` +git clone https://codeberg.org/vyn/focus.git +cd focus +git submodule update --init --recursive +``` + +To build: +``` +cmake -DCMAKE_BUILD_TYPE=Release -S . -B ./build -G Ninja +cd build +ninja +``` +Then you should have a `focus` executable in the `build` directory you are currently in. + +## Contributing + +Feel free to make suggestions and report issues, but I do **not** accept contributions (pull requests). + +## License + +Copyright (C) Vyn 2024 + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, under version 3 of the License only. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program in the LICENSE file. +If not, see . diff --git a/external/selenite b/external/selenite new file mode 160000 index 0000000..1774720 --- /dev/null +++ b/external/selenite @@ -0,0 +1 @@ +Subproject commit 1774720377afc932cb92303516e049f66d8bf7a0 diff --git a/images/presentation-1.png b/images/presentation-1.png new file mode 100644 index 0000000..397d46b Binary files /dev/null and b/images/presentation-1.png differ diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..497f0a3 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,147 @@ +#include "app-window.h" +#include "slint_timer.h" +#include "slint.h" +#include + + + +class CountdownState { + public: + CountdownState(slint::ComponentHandle ui) : ui_(ui) { + ui_->global().set_break_countdown_duration(60 * 5); + ui_->global().set_focus_countdown_duration(60 * 25); + ui_->global().set_max_session_count(4); + reset(); + } + + ui::SessionStep sessionStep() const { + return ui_->global().get_sessions_step(); + } + + int currentSession() const { + return ui_->global().get_current_session(); + } + + void setCurrentSession(int newCurrentSession) const { + ui_->global().set_current_session(newCurrentSession); + } + + int maxSessions() const { + return ui_->global().get_max_session_count(); + } + + void setSessionStep(ui::SessionStep newSessionStep) { + ui_->global().set_sessions_step(newSessionStep); + } + + ui::CountdownStatus countdownStatus() const { + return ui_->global().get_countdown_status(); + } + + void setCountdownStatus(ui::CountdownStatus newCountdownStatus) { + ui_->global().set_countdown_status(newCountdownStatus); + } + + void setCountdown(int newCountdownValue) { + ui_->global().set_countdown(newCountdownValue); + } + + int countdown() const { + return ui_->global().get_countdown(); + } + + void toggleCountdown() { + if (countdownStatus() == ui::CountdownStatus::Running) { + pause(); + } else if (countdownStatus() == ui::CountdownStatus::Paused || countdownStatus() == ui::CountdownStatus::NotStarted) { + start(); + } + } + + void goNextStep() { + if (sessionStep() == ui::SessionStep::Setup) { + setSessionStep(ui::SessionStep::Focus); + setCountdownStatus(ui::CountdownStatus::NotStarted); + setCountdown(ui_->global().get_focus_countdown_duration()); + setCurrentSession(currentSession() + 1); + } else if (sessionStep() == ui::SessionStep::Focus) { + setSessionStep(ui::SessionStep::Break); + setCountdownStatus(ui::CountdownStatus::NotStarted); + setCountdown(ui_->global().get_break_countdown_duration()); + } else if (sessionStep() == ui::SessionStep::Break) { + setCurrentSession(currentSession() + 1); + if (currentSession() < maxSessions()) { + setSessionStep(ui::SessionStep::Focus); + setCountdownStatus(ui::CountdownStatus::NotStarted); + setCountdown(ui_->global().get_focus_countdown_duration()); + } else { + setSessionStep(ui::SessionStep::Finished); + } + } + } + + void start() { + setCountdownStatus(ui::CountdownStatus::Running); + timer_.start(slint::TimerMode::Repeated, std::chrono::milliseconds(1000), [&]{ + onTimerTick(&timer_, ui_); + }); + } + + void pause() { + setCountdownStatus(ui::CountdownStatus::Paused); + timer_.stop(); + } + + void reset() { + timer_.stop(); + setSessionStep(ui::SessionStep::Setup); + setCountdownStatus(ui::CountdownStatus::NotStarted); + setCountdown(ui_->global().get_focus_countdown_duration()); + setCurrentSession(0); + } + + void onTimerTick(slint::Timer* timer, slint::ComponentHandle ui) { + int countdown = ui->global().get_countdown(); + countdown--; + ui->global().set_countdown(countdown); + + if (countdown == 0) { + timer->stop(); + ui->global().set_countdown_status(ui::CountdownStatus::NotStarted); + // TODO make it customizable + system("notify-send -t 0 \"Pomodoro\" \"task\" &"); + system("paplay /usr/share/sounds/freedesktop/stereo/complete.oga &"); + + goNextStep(); + } + } + + private: + + slint::ComponentHandle ui_; + slint::Timer timer_; +}; + +int main(int argc, char **argv) +{ + slint::ComponentHandle ui = ui::AppWindow::create(); + + CountdownState countdown(ui); + + ui->on_start_stop([&]{ + if (countdown.sessionStep() == ui::SessionStep::Setup || countdown.sessionStep() == ui::SessionStep::Finished) { + countdown.reset(); + countdown.goNextStep(); + countdown.start(); + } else if (countdown.sessionStep() == ui::SessionStep::Focus || countdown.sessionStep() == ui::SessionStep::Break) { + countdown.toggleCountdown(); + } + }); + + ui->global().on_config_changed([&] { + countdown.reset(); + }); + + ui->run(); + return 0; +} diff --git a/ui/app-window.slint b/ui/app-window.slint new file mode 100644 index 0000000..c5ae98e --- /dev/null +++ b/ui/app-window.slint @@ -0,0 +1,54 @@ +import { State, SessionStep, CountdownStatus } from "./state.slint"; +import { VText, VButton, VActionButton, Svg, Palette } from "@selenite"; +import { CountdownView } from "./countdown-view.slint"; +import { SettingsView } from "settings-view.slint"; + +enum CurrentView { + Countdown, + Settings +} + +export component AppWindow inherits Window { + title: "Focus"; + callback start-stop; + property current-view: CurrentView.Countdown; + + background: Palette.background; + default-font-size: 16px; + padding: 0px; + + VerticalLayout { + width: parent.width; + height: parent.height; + + HorizontalLayout { + padding: 8px; + vertical-stretch: 0; + alignment: start; + settingsButtons := VActionButton { + icon-svg: Svg.burger; + background: Palette.background.transparentize(1); + clicked => { + if (current-view == CurrentView.Countdown) { + current-view = CurrentView.Settings; + } else if (current-view == CurrentView.Settings) { + current-view = CurrentView.Countdown; + State.config-changed(); + } + } + } + } + if current-view == CurrentView.Countdown : countdown-view := CountdownView { + start-stop => { root.start-stop() } + vertical-stretch: 1; + padding: 32px; + padding-top: 0px; + } + if current-view == CurrentView.Settings : settings-view := SettingsView { + vertical-stretch: 1; + } + } + +} + +export { State } diff --git a/ui/countdown-view.slint b/ui/countdown-view.slint new file mode 100644 index 0000000..6f413ba --- /dev/null +++ b/ui/countdown-view.slint @@ -0,0 +1,68 @@ +import { State, SessionStep, CountdownStatus } from "./state.slint"; +import { VText, VButton, VActionButton, Svg, Palette } from "@selenite"; +import { Utils } from "utils.slint"; + +export component CountdownView inherits VerticalLayout { + callback start-stop <=> startStopButton.clicked; + + pure function format-session-step(session-step: SessionStep) -> string { + if (session-step == SessionStep.Setup) { + return "Setup"; + } + if (session-step == SessionStep.Focus) { + return "Focus"; + } + if (session-step == SessionStep.Break) { + return "Break"; + } + if (session-step == SessionStep.Finished) { + return "Finished"; + } + return "Not implemented"; + } + + VerticalLayout { + + spacing: 32px; + alignment: center; + + VerticalLayout { + VText { + font-size: 3rem; + letter-spacing: 0.2rem; + font-weight: 600; + text: "\{Utils.format-countdown(State.countdown)}"; + horizontal-alignment: center; + } + VText { + color: Palette.foreground-hint; + text: format-session-step(State.sessions-step); + horizontal-alignment: center; + } + } + HorizontalLayout { + alignment: space-between; + spacing: 8px; + for step[step-index] in State.max-session-count: Rectangle { + preferred-width: 16px; + preferred-height: 16px; + border-radius: 32px; + background: step-index + 1 == State.current-session + ? Palette.accent.transparentize(0.5) + : step-index + 1 < State.current-session ? Palette.accent + : Palette.control-background; + animate background { + duration: 0.5s; + } + } + } + HorizontalLayout { + alignment: center; + startStopButton := VActionButton { + background: Palette.foreground.transparentize(1); + icon-size: 2rem; + icon-svg: State.countdown-status != CountdownStatus.Running ? Svg.play : Svg.pause; + } + } + } +} diff --git a/ui/settings-view.slint b/ui/settings-view.slint new file mode 100644 index 0000000..9513516 --- /dev/null +++ b/ui/settings-view.slint @@ -0,0 +1,44 @@ +import { State, SessionStep, CountdownStatus } from "./state.slint"; +import { VText, VButton, VSlider, Palette } from "@selenite"; +import { Utils } from "utils.slint"; + +export component SettingsView inherits Rectangle { + VerticalLayout { + padding: 32px; + spacing: 32px; + alignment: center; + VSlider { + label: "Session duration"; + label-size: 1.25rem; + label-alignment: TextHorizontalAlignment.center; + no-background: true; + minimum: 1; + maximum: 60 * 60; + value: State.focus-countdown-duration; + format-value(value) => { return Utils.format-countdown(value); } + released(value) => { State.focus-countdown-duration = value; } + } + VSlider { + label: "Break duration"; + label-size: 1.25rem; + label-alignment: TextHorizontalAlignment.center; + no-background: true; + minimum: 1; + maximum: 60 * 60; + value: State.break-countdown-duration; + format-value(value) => { return Utils.format-countdown(value); } + released(value) => { State.break-countdown-duration = value; } + } + VSlider { + label: "Number of session"; + label-size: 1.25rem; + label-alignment: TextHorizontalAlignment.center; + no-background: true; + minimum: 1; + maximum: 8; + value: State.max-session-count; + format-value(value) => { return Math.round(value); } + released(value) => { State.max-session-count = Math.round(value); } + } + } +} diff --git a/ui/state.slint b/ui/state.slint new file mode 100644 index 0000000..8366425 --- /dev/null +++ b/ui/state.slint @@ -0,0 +1,24 @@ +export enum SessionStep { + Setup, + Focus, + Break, + Finished, +} + +export enum CountdownStatus { + Running, + Paused, + NotStarted +} + +export global State { + in-out property countdown; + in-out property focus-countdown-duration; + in-out property break-countdown-duration; + in-out property sessions-step; + in-out property countdown-status; + in-out property current-session; + in-out property max-session-count; + + callback config-changed(); +} diff --git a/ui/utils.slint b/ui/utils.slint new file mode 100644 index 0000000..9203df4 --- /dev/null +++ b/ui/utils.slint @@ -0,0 +1,12 @@ +export global Utils { + public pure function format-zero-padding(number: int) -> string { + if (number < 10) { + return "0\{number}"; + } + return number; + } + + public pure function format-countdown(countdown: int) -> string { + return "\{format-zero-padding(countdown / 60)}:\{format-zero-padding(Math.mod(countdown, 60))}"; + } +}