diff --git a/pomodoro/JetBrainsMono-Regular.ttf b/pomodoro/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..436c982 Binary files /dev/null and b/pomodoro/JetBrainsMono-Regular.ttf differ diff --git a/pomodoro/LICENCE.txt b/pomodoro/LICENCE.txt new file mode 100644 index 0000000..d561704 --- /dev/null +++ b/pomodoro/LICENCE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kirill Sergeev + +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/pomodoro/README.md b/pomodoro/README.md new file mode 100644 index 0000000..b665cd5 --- /dev/null +++ b/pomodoro/README.md @@ -0,0 +1,53 @@ +# Pomodoro Timer + +A Pomodoro timer plugin for Noctalia for productivity. Initially ported from the legacy v4 plugin [Pomodoro Timer](https://github.com/noctalia-dev/legacy-v4-plugins/tree/main/pomodoro). + +## Features +- **Sessions**: Configurable sessions based on the standard format (work - short break - long break). Durations can be configured in the settings. +- **Cycles**: Configurable number of (work - short break) cycles before a long break. +- **Auto-start**: Optionally auto-start breaks and/or work sessions. +- **Bar Widget**: Shows status and remaining time on the bar widget when the panel is closed. +- **Notifications**: Toast notification when work/break finishes. + +## TODO +- Sound notification (currently the toast is shown silently) +- IPC + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `thepunkoff/pomodoro` | +| Entries | Bar widget: `widget`; panel: `panel`; service: `pomodoro` | + +## Usage +1. Enable plugin in settings +2. Add bar widget `Pomodoro Timer` +3. Widget appears on the bar, clicking it will toggle the panel. + +To open the panel with a command: +```sh +noctalia msg panel-toggle thepunkoff/pomodoro:panel +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `work-duration` | `int` | `25` | Duration of each work session in minutes. | +| `short-break-duration` | `int` | `5` | Duration of short breaks in minutes. | +| `long-break-duration` | `int` | `15` | Duration of long breaks in minutes. | +| `sessions-before-long-break` | `int` | `4` | Number of sessions before a long break (min=1). | +| `auto-start-work` | `bool` | `false` | Automatically start the work timer after a break. | +| `auto-start-breaks` | `bool` | `false` | Automatically start the break timer after a work session. | + +## IPC +```sh +noctalia msg panel-toggle thepunkoff/pomodoro:panel +``` + +## Licensing + +This project is licensed under the MIT License. + +It bundles the JetBrains Mono font, which is licensed separately under the SIL Open Font License 1.1 (OFL-1.1). See `THIRD_PARTY_LICENCES/OFL.txt` for the full license text. diff --git a/pomodoro/THIRD_PARTY_LICENCES/OFL.txt b/pomodoro/THIRD_PARTY_LICENCES/OFL.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/pomodoro/THIRD_PARTY_LICENCES/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/pomodoro/panel.luau b/pomodoro/panel.luau new file mode 100644 index 0000000..642f39a --- /dev/null +++ b/pomodoro/panel.luau @@ -0,0 +1,178 @@ +local timeFontFamily = noctalia.loadFont("JetBrainsMono-Regular.ttf") + +local function formatTime(seconds, total) + local showingHours = total >= 3600 or seconds >= 3600 + local hours = math.floor(seconds / 3600) + local mins = math.floor((seconds % 3600) / 60) + local secs = seconds % 60 + + if showingHours then + return string.format("%d:%02d:%02d", hours, mins, secs) + else + return string.format("%02d:%02d", mins, secs) + end +end + +function onStartButtonClick() + noctalia.state.set("pomodoro.nextCommand", "toggle") +end + +function onSkipButtonClick() + noctalia.state.set("pomodoro.nextCommand", "skip") +end + +function onResetButtonClick() + noctalia.state.set("pomodoro.nextCommand", "reset") +end + +function onResetAllButtonClick() + noctalia.state.set("pomodoro.nextCommand", "resetAll") +end + +local function render(state, sessionData) + local sessionNumber = state.sessionPtr.session + local session = sessionData[sessionNumber] + local stageNumber = state.sessionPtr.stage + local stageTotalSeconds = session[stageNumber] + + local stageName = + if stageNumber == 1 then + noctalia.tr("work") + else if sessionNumber < #sessionData then + noctalia.tr("short-break") + else + noctalia.tr("long-break") + + local startButtonText = + if state.isRunning then + noctalia.tr("button.pause") + else if state.secondsLeft > 0 and state.secondsLeft < stageTotalSeconds then + noctalia.tr("button.resume") + else + noctalia.tr("button.start") + + local startButtonGlyph = if state.isRunning then "player-pause" else "player-play" + + local skipButtonEnabled = state.isDirty and (if state.isRunning then state.secondsLeft > 0 else stageTotalSeconds > 0) + + local resetButtonEnabled = state.isDirty and state.secondsLeft > 0 + + local resetAllButtonEnabled = state.isDirty and (state.isRunning or state.secondsLeft > 0 or (state.sessionPtr.session > 1 and state.sessionPtr.stage > 1)) + + local stageIcon = if state.sessionPtr.stage == 1 then "brain" else "coffee" + + panel.render( + ui.column({ flexGrow = 1, gap = 16 }, { + -- header + ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = stageIcon, color = "primary" }), + + ui.label({ + text = "Pomodoro", + fontSize = 18, + fontWeight = "bold", + color = "on_surface", + flexGrow = 1, + }), + + ui.label({ + text = string.format("%s %d/%d", noctalia.tr("session"), sessionNumber, #sessionData), + fontSize = 12, + color = "on_surface_variant", + }), + }), + + -- time + ui.column({ flexGrow = 1, align = "center", justify = "center", }, { + ui.label({ + text = stageName, + fontSize = 18, + fontWeight = "medium", + color = "primary", + }), + + ui.label({ + text = formatTime(state.secondsLeft, stageTotalSeconds), + fontSize = 42, + fontWeight = "bold", + fontFamily = timeFontFamily, + color = "primary", + }), + }), + + -- buttons + ui.column({ gap = 8 }, { + ui.row({ gap = 8, justify = "space_between" }, { + ui.button({ + variant = "primary", + text = startButtonText, + glyph = startButtonGlyph, + onClick = "onStartButtonClick", + flexGrow = 1, + }), + + ui.button({ + text = noctalia.tr("button.skip"), + glyph = "player-skip-forward", + onClick = "onSkipButtonClick", + enabled = skipButtonEnabled, + flexGrow = 1, + }), + }), + + ui.row({ gap = 8, justify = "space_between" }, { + ui.button({ + text = noctalia.tr("button.reset"), + glyph = "refresh", + onClick = "onResetButtonClick", + enabled = resetButtonEnabled, + flexGrow = 1, + }), + + ui.button({ + text = noctalia.tr("button.reset-all"), + glyph = "rotate", + onClick = "onResetAllButtonClick", + enabled = resetAllButtonEnabled, + flexGrow = 1, + }), + }), + }), + }) -- column + ) -- panel +end + +function onOpen(context) + local state = noctalia.state.get("pomodoro.state") + local sessionData = noctalia.state.get("pomodoro.sessionData") + render(state, sessionData) +end + +local function notifyStageFinished(state, sessionData) + local sessionNumber = state.sessionPtr.session + local stageNumber = state.sessionPtr.stage + local body = + if stageNumber == 1 then + noctalia.tr("notification.work-complete") + elseif sessionNumber < #sessionData then + noctalia.tr("notification.short-break-complete") + else + noctalia.tr("notification.long-break-complete") + noctalia.notify("Pomodoro Timer", body) +end + +--- main +noctalia.state.watch("pomodoro.state", function(state) + local config = noctalia.state.get("pomodoro.config") + local sessionData = noctalia.state.get("pomodoro.sessionData") + + if state.secondsLeft == 0 then + notifyStageFinished(state, sessionData) + noctalia.state.set("pomodoro.nextCommand", "skip") + if (config.autoStartWork and state.sessionPtr.stage == 2) or (config.autoStartBreaks and state.sessionPtr.stage == 1) then + noctalia.state.set("pomodoro.nextCommand", "toggle") + end + end + + render(state, sessionData) +end) diff --git a/pomodoro/plugin.toml b/pomodoro/plugin.toml new file mode 100644 index 0000000..ab83cc6 --- /dev/null +++ b/pomodoro/plugin.toml @@ -0,0 +1,69 @@ +id = "thepunkoff/pomodoro" +name = "Pomodoro Timer" +version = "1.0.0" +plugin_api = 3 +author = "thepunkoff" +license = "MIT" +icon = "brain" +description = "Simple pomodoro timer." +tags = ["bar", "panel", "service", "productivity", "time"] +dependencies = [] + +[[setting]] +key = "work-duration" +type = "int" +label_key = "settings.work-duration.label" +description_key = "settings.work-duration.description" +default = 25 + +[[setting]] +key = "short-break-duration" +type = "int" +label_key = "settings.short-break-duration.label" +description_key = "settings.short-break-duration.description" +default = 5 + +[[setting]] +key = "long-break-duration" +type = "int" +label_key = "settings.long-break-duration.label" +description_key = "settings.long-break-duration.description" +default = 15 + +[[setting]] +key = "sessions-before-long-break" +type = "int" +label_key = "settings.sessions-before-long-break.label" +description_key = "settings.sessions-before-long-break.description" +default = 4 +min = 1 + +[[setting]] +key = "auto-start-breaks" +type = "bool" +label_key = "settings.auto-start-breaks.label" +description_key = "settings.auto-start-breaks.description" +default = false + +[[setting]] +key = "auto-start-work" +type = "bool" +label_key = "settings.auto-start-work.label" +description_key = "settings.auto-start-work.description" +default = false + +[[widget]] +id = "widget" +entry = "widget.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +placement = "attached" +open_near_click = true +width = 350 +height = 250 + +[[service]] +id = "pomodoro" +entry = "pomodoro.luau" diff --git a/pomodoro/pomodoro.luau b/pomodoro/pomodoro.luau new file mode 100644 index 0000000..18e04b5 --- /dev/null +++ b/pomodoro/pomodoro.luau @@ -0,0 +1,83 @@ +local config = (function() + return { + workDuration = noctalia.getConfig("work-duration"), + shortBreakDuration = noctalia.getConfig("short-break-duration"), + longBreakDuration = noctalia.getConfig("long-break-duration"), + sessionsBeforeLongBreak = noctalia.getConfig("sessions-before-long-break"), + autoStartBreaks = noctalia.getConfig("auto-start-breaks"), + autoStartWork = noctalia.getConfig("auto-start-work"), + } +end)() +noctalia.state.set("pomodoro.config", config) + +local sessionData = {} +for i = 1, config.sessionsBeforeLongBreak do + breakDuration = if i < config.sessionsBeforeLongBreak then config.shortBreakDuration else config.longBreakDuration + table.insert(sessionData, { config.workDuration * 60, breakDuration * 60 }) +end +noctalia.state.set("pomodoro.sessionData", sessionData) + +local isRunning = false +local sessionPtr = { session = 1, stage = 1 } +local isDirty = false +local secondsLeft = 0 + +local function getStateSnapshot() + return { + isRunning = isRunning, + secondsLeft = secondsLeft, + sessionPtr = sessionPtr, + isDirty = isDirty, + } +end + +local function getStageTotalSeconds() + local sessionNumber = sessionPtr.session + local session = sessionData[sessionNumber] + local stageNumber = sessionPtr.stage + return session[stageNumber] +end + +--- main +secondsLeft = getStageTotalSeconds() + +noctalia.state.watch("pomodoro.nextCommand", function(command) + if command == "toggle" then + isDirty = true + isRunning = not isRunning + elseif command == "skip" then + isRunning = false + if sessionPtr.stage == 1 then + sessionPtr.stage = 2 + elseif sessionPtr.session < #sessionData then + sessionPtr.session += 1 + sessionPtr.stage = 1 + elseif sessionPtr.session == #sessionData then + sessionPtr = { session = 1, stage = 1 } + end + secondsLeft = getStageTotalSeconds() + elseif command == "reset" then + isRunning = false + secondsLeft = getStageTotalSeconds() + elseif command == "resetAll" then + isRunning = false + sessionPtr = { session = 1, stage = 1 } + secondsLeft = getStageTotalSeconds() + isDirty = false + end + + local state = getStateSnapshot() + noctalia.state.set("pomodoro.state", state) +end) + +local state = getStateSnapshot() +noctalia.state.set("pomodoro.state", state) + +noctalia.setUpdateInterval(1000) +function update() + if isRunning then + secondsLeft -= 1 + local state = getStateSnapshot() + noctalia.state.set("pomodoro.state", state) + end +end diff --git a/pomodoro/thumbnail.webp b/pomodoro/thumbnail.webp new file mode 100644 index 0000000..f6a4a59 Binary files /dev/null and b/pomodoro/thumbnail.webp differ diff --git a/pomodoro/translations/en.json b/pomodoro/translations/en.json new file mode 100644 index 0000000..36b690a --- /dev/null +++ b/pomodoro/translations/en.json @@ -0,0 +1,45 @@ +{ + "work": "Work", + "short-break": "Short Break", + "long-break": "Long Break", + "session": "Session", + "button": { + "start": "Start", + "resume": "Resume", + "pause": "Pause", + "skip": "Skip", + "reset": "Reset", + "reset-all": "Reset All" + }, + "notification": { + "work-complete": "Work session complete! Time for a break.", + "short-break-complete": "Break over! Ready to focus?", + "long-break-complete": "Long break over! Ready for a new cycle?" + }, + "settings": { + "work-duration":{ + "label": "Work Duration", + "description": "Duration of each work session in minutes" + }, + "short-break-duration":{ + "label": "Short Break Duration", + "description": "Duration of short breaks in minutes" + }, + "long-break-duration":{ + "label": "Long Break Duration", + "description": "Duration of long breaks in minutes" + }, + "sessions-before-long-break":{ + "label": "Sessions Before Long Break", + "description": "Number of sessions before a long break" + }, + "auto-start-work":{ + "label": "Auto-start Work", + "description": "Automatically start work timer after break" + }, + "auto-start-breaks":{ + "label": "Auto-start Breaks", + "description": "Automatically start break timer after work session" + } + } +} diff --git a/pomodoro/widget.luau b/pomodoro/widget.luau new file mode 100644 index 0000000..85238ab --- /dev/null +++ b/pomodoro/widget.luau @@ -0,0 +1,43 @@ +function onClick() + noctalia.togglePanel("thepunkoff/pomodoro:panel") +end + +local isUpdating = false +noctalia.state.watch("pomodoro.nextCommand", function(command) + if command == "toggle" then + isUpdating = true + elseif command == "resetAll" then + isUpdating = false + barWidget.setGlyph("brain") + barWidget.setGlyphColor("default") + barWidget.setColor("default") + barWidget.setText("") + end +end) + +--- main +barWidget.setGlyph("brain") + +noctalia.state.watch("pomodoro.state", function(state) + if not isUpdating then + return + end + + local stageIcon = if state.sessionPtr.stage == 1 then "brain" else "coffee" + barWidget.setGlyph(stageIcon) + + if state.isRunning then + barWidget.setGlyphColor("primary") + barWidget.setColor("primary") + else + barWidget.setGlyphColor("default") + barWidget.setColor("default") + end + + local hours = math.floor(state.secondsLeft / 3600) + local mins = math.floor((state.secondsLeft % 3600) / 60) + local secs = state.secondsLeft % 60 + + local time = if hours == 0 then string.format("%02d:%02d", mins, secs) else string.format("%d:%02d:%02d", hours, mins, secs) + barWidget.setText(time) +end)