Compare commits

...

4 Commits

Author SHA1 Message Date
263b4edd07 Active display of a timer 2022-07-29 04:23:40 +02:00
f67d5f6a2e Moved timers into a plugin 2022-07-28 02:52:31 +02:00
227160e044 Keep a database of timers 2022-07-28 01:47:53 +02:00
bf940f418e Minor license stuff update 2022-07-28 01:47:36 +02:00
11 changed files with 430 additions and 161 deletions

7
.vscode/tasks.json vendored
View File

@@ -28,6 +28,13 @@
"command": "npm", "command": "npm",
"problemMatcher": [], "problemMatcher": [],
"args": ["run", "build"] "args": ["run", "build"]
},
{
"label": "ui:format",
"type": "shell",
"command": "npm",
"problemMatcher": [],
"args": ["run", "format"]
} }
] ]
} }

View File

@@ -1,7 +1,7 @@
GNU AFFERO GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed. of this license document, but changing it is not allowed.
@@ -643,7 +643,7 @@ the "copyright" line and a pointer to where the full notice is found.
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
@@ -658,4 +658,4 @@ specific requirements.
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>. <http://www.gnu.org/licenses/>.

View File

@@ -1,84 +1,15 @@
// Copyright 2022 ModZero.
// SPDX-License-Identifier: AGPL-3.0-or-later
#![cfg_attr( #![cfg_attr(
all(not(debug_assertions), target_os = "windows"), all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
use std::time::{Duration, Instant}; mod timers;
use chrono::{DateTime, FixedOffset, Local};
use tauri::{async_runtime::spawn, Window};
use tokio::time::interval;
use uuid::Uuid;
#[derive(Clone, Default, serde::Serialize)]
struct Timer {
id: Uuid,
started: Option<DateTime<FixedOffset>>,
duration: Duration,
elapsed: Option<Duration>,
message: String,
}
impl Timer {
fn new(message: &str, duration: Duration) -> Self {
Self {
id: Uuid::new_v4(),
duration,
message: message.to_string(),
..Default::default()
}
}
fn complete(&self) -> bool {
self.elapsed.map_or(false, |e| e >= self.duration)
}
async fn run(&mut self, window: Window) {
self.started = Some(Local::now().into());
let mut elapsed = Duration::from_secs(0);
self.elapsed = Some(elapsed);
let mut last_checked = Instant::now();
let mut interval = interval(Duration::from_secs(1) / 60);
loop {
interval.tick().await;
let now = Instant::now();
let duration = now - last_checked;
elapsed += duration;
self.elapsed = Some(elapsed);
if self.complete() {
break;
}
if window.emit("timer-tick", self.clone()).is_err() {
break;
}
last_checked = now;
}
window
.emit("timer-done", self.clone())
.expect("Our window went away?");
}
}
#[tauri::command]
fn start_timer(window: Window, duration: Duration, message: &str) -> Uuid {
let mut timer = Timer::new(message, duration);
let timer_id = timer.id;
spawn(async move {
timer.run(window).await;
});
timer_id
}
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.invoke_handler(tauri::generate_handler![start_timer]) .plugin(timers::init())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

206
src-tauri/src/timers.rs Normal file
View File

@@ -0,0 +1,206 @@
// Copyright 2022 ModZero.
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::{
collections::HashMap,
error::Error,
fmt,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use chrono::{DateTime, FixedOffset, Local};
use tauri::{
async_runtime::spawn,
plugin::{Builder, TauriPlugin},
AppHandle, Manager, Runtime, State,
};
use tokio::time::interval;
use uuid::Uuid;
#[derive(serde::Serialize, Debug)]
pub enum TimerError {
NotFound,
NotStarted,
}
impl fmt::Display for TimerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TimerError::NotFound => write!(f, "timer not found"),
TimerError::NotStarted => write!(f, "timer not started"),
}
}
}
impl Error for TimerError {}
#[derive(Clone, Default, serde::Serialize)]
pub struct Timer {
id: Uuid,
started: Option<DateTime<FixedOffset>>,
duration: Duration,
elapsed: Option<Duration>,
#[serde(skip)]
checked: Option<Instant>,
version: u64,
}
impl Timer {
fn new(duration: Duration) -> Self {
Self {
id: Uuid::new_v4(),
duration,
..Default::default()
}
}
fn is_complete(&self) -> bool {
self.elapsed.map_or(false, |e| e >= self.duration)
}
fn start(&mut self) {
let now = Local::now().into();
self.started = Some(now);
self.elapsed = Some(Duration::from_secs(0));
self.checked = Some(Instant::now());
self.version += 1;
}
/// Increment the timer, returning the time since last tick
fn tick(&mut self) -> Result<(), TimerError> {
let now = Instant::now();
match self.checked {
None => Err(TimerError::NotStarted),
Some(checked) => {
self.elapsed = Some(now - checked);
self.checked = Some(checked);
self.version += 1;
Ok(())
}
}
}
fn reset(&mut self, duration: Duration) {
self.duration = duration;
self.started = None;
self.elapsed = None;
self.checked = None;
self.version += 1;
}
}
#[derive(Default)]
struct Timers(Arc<Mutex<HashMap<Uuid, Timer>>>);
impl Timers {
fn make(&self, duration: Duration) -> Timer {
let timer = Timer::new(duration);
self.0.lock().unwrap().insert(timer.id, timer.clone());
timer
}
fn delete(&self, timer_id: Uuid) -> Option<Timer> {
self.0.lock().unwrap().get(&timer_id).cloned()
}
fn start(&self, timer_id: Uuid) -> Result<Timer, TimerError> {
let mut timers = self.0.lock().unwrap();
match timers.get_mut(&timer_id) {
None => Err(TimerError::NotFound),
Some(t) => {
t.start();
Ok(t.clone())
}
}
}
fn tick(&self, timer_id: Uuid) -> Result<Timer, TimerError> {
let mut timers = self.0.lock().unwrap();
match timers.get_mut(&timer_id) {
None => Err(TimerError::NotFound),
Some(t) => t.tick().and(Ok(t.clone())),
}
}
fn reset(&self, timer_id: Uuid, duration: Duration) -> Result<Timer, TimerError> {
let mut timers = self.0.lock().unwrap();
match timers.get_mut(&timer_id) {
None => Err(TimerError::NotFound),
Some(t) => {
t.reset(duration);
Ok(t.clone())
}
}
}
}
#[tauri::command]
fn make(timers: State<'_, Timers>, duration: Duration) -> Timer {
timers.make(duration)
}
#[tauri::command]
fn delete(timers: State<'_, Timers>, timer_id: Uuid) -> Option<Timer> {
timers.delete(timer_id)
}
#[tauri::command]
fn reset<R: Runtime>(
_app: AppHandle<R>,
timers: State<'_, Timers>,
timer_id: Uuid,
duration: Duration,
) -> Result<Timer, TimerError> {
let res = timers.reset(timer_id, duration);
if let Ok(timer) = &res {
_app.emit_all("timer-update", timer).ok();
}
res
}
#[tauri::command]
fn start<R: Runtime>(
_app: AppHandle<R>,
timers: State<'_, Timers>,
timer_id: Uuid,
) -> Result<Timer, TimerError> {
let timers = Timers(timers.0.to_owned());
let timer = timers.start(timer_id)?;
spawn(async move {
let mut interval = interval(Duration::from_secs(1) / 60);
loop {
interval.tick().await;
match &timers.tick(timer_id) {
Err(_) => break, // Timer is gone or no longer running, we're done
Ok(timer) => {
_app.emit_all("timer-update", timer).ok();
if timer.is_complete() {
break;
}
}
}
}
});
Ok(timer)
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("timers")
.invoke_handler(tauri::generate_handler![delete, make, reset, start,])
.setup(|app_handle| {
app_handle.manage(Timers::default());
Ok(())
})
.build()
}

View File

@@ -1,48 +1,23 @@
<!--
Copyright 2022 ModZero.
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script type="ts"> <script type="ts">
import { onDestroy, onMount } from "svelte"; import Timer from "./components/Timer.svelte";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api";
import { listen } from "@tauri-apps/api/event";
let seconds = 5;
let timer_tick_unlisten: Promise<UnlistenFn> | null = null;
let timer_done_unlisten: Promise<UnlistenFn> | null = null;
type Timer = {
id: string;
elapsed: {
secs: number;
nsecs: number;
};
};
onMount(() => {
timer_tick_unlisten = listen<Timer>("timer-tick", (event) => {
console.log("Tick!", event.payload.id, event.payload.elapsed);
});
timer_done_unlisten = listen<Timer>("timer-done", (event) => {
console.log("Done!", event.payload.id);
});
});
onDestroy(() => {
timer_tick_unlisten?.then((ttu) => ttu());
timer_done_unlisten?.then((tdu) => tdu());
});
function start_timer() {
invoke("start_timer", {
duration: { secs: seconds, nanos: 0 },
message: "Hi!",
});
}
</script> </script>
<main> <main>
<label> <Timer />
Fire after
<input type="number" bind:value={seconds} />
</label>
<button on:click={start_timer}>Fire!</button>
</main> </main>
<style>
main {
position: fixed;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -1,5 +1,5 @@
:root { :root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif; font-family: Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 16px;
line-height: 24px; line-height: 24px;
font-weight: 400; font-weight: 400;
@@ -26,7 +26,6 @@ a:hover {
body { body {
margin: 0; margin: 0;
display: flex;
place-items: center; place-items: center;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
@@ -37,45 +36,14 @@ h1 {
line-height: 1.1; line-height: 1.1;
} }
.card {
padding: 2em;
}
#app { #app {
position: absolute;
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 0;
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
} top: 0;
bottom: 0;
button { left: 0;
border-radius: 8px; right: 0;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
} }

View File

@@ -0,0 +1,63 @@
<!--
Copyright 2022 ModZero.
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { Timer } from "@app/lib/timer";
let seconds = 5;
let timer: Timer = new Timer({ secs: seconds, nanos: 0 });
$: timer.ready && timer.reset({ secs: seconds, nanos: 0 });
$: console.log(seconds);
onDestroy(() => {
timer.close();
});
</script>
<div class="timer" />
<div class="meter">
{#if $timer !== null}
{$timer.elapsed ? `${$timer.elapsed.secs}.${$timer.elapsed.nanos}` : `0.0`} /
{$timer.duration.secs}
{:else}
...
{/if}
</div>
<div class="controls">
<label>
Fire after
<input
disabled={$timer === null || !!$timer.elapsed}
type="number"
bind:value={seconds}
/>
</label>
<button
disabled={$timer === null || !!$timer.elapsed}
on:click={() => timer.start()}>Fire!</button
>
<button
disabled={$timer === null}
on:click={() => timer.reset({ secs: seconds, nanos: 0 })}>Reset</button
>
</div>
<style>
.timer {
display: flex;
flex-direction: column;
}
.meter {
flex-grow: 1;
margin: 0.5em;
}
.controls {
margin: 0.5em;
padding: 0.5em;
}
</style>

111
src/lib/timer.ts Normal file
View File

@@ -0,0 +1,111 @@
// Copyright 2022 ModZero.
// SPDX-License-Identifier: AGPL-3.0-or-later
import { invoke } from "@tauri-apps/api";
import { listen, type Event, type UnlistenFn } from "@tauri-apps/api/event";
import {
writable,
type Readable,
type Subscriber,
type Unsubscriber,
} from "svelte/store";
export type Duration = {
readonly secs: number;
readonly nanos: number;
};
export type TimerData = {
readonly id: string;
readonly duration: Duration;
readonly elapsed: Duration | null;
readonly version: number;
};
export class Timer implements Readable<TimerData> {
private subscribers: Set<Subscriber<TimerData>> = new Set();
private _data: TimerData | null = null;
private get data(): TimerData | null {
return this._data;
}
private set data(v: TimerData | null) {
this._data = v;
this.subscribers.forEach((s) => s(v));
}
x = writable(0);
public subscribe(run: Subscriber<TimerData>): Unsubscriber {
run(this.data);
this.subscribers.add(run);
return () => {
this.subscribers.delete(run);
};
}
private ensureReady() {
if (!this.ready) {
throw new Error("Timer Still Processing");
}
}
private _duration: Duration | null;
public get duration(): Duration | null {
return this._duration;
}
public reset(duration: Duration) {
this.ensureReady();
invoke("plugin:timers|reset", {
timerId: this.data.id,
duration: duration,
});
}
private _elapsed: Duration | null;
public get elapsed(): Duration | null {
return this._elapsed;
}
private unlistenUpdate: Promise<UnlistenFn> | null = null;
private onUpdate(event: Event<TimerData>): void {
if (
event.payload.id === this.data.id &&
event.payload.version >= this.data.version
) {
this.data = event.payload;
}
}
public get ready() {
return this.data !== null;
}
constructor(duration: Duration) {
this.unlistenUpdate = listen<TimerData>("timer-update", (event) =>
this.onUpdate(event)
);
invoke<TimerData>("plugin:timers|make", {
duration: duration,
}).then((timer) => {
this.data = timer;
});
}
public close() {
if (this.ready) {
invoke("plugin:timers|delete", { timerId: this.data.id });
}
this.unlistenUpdate.then((u) => u());
}
public start() {
this.ensureReady();
invoke("plugin:timers|start", { timerId: this.data.id });
}
}

View File

@@ -1,10 +1,11 @@
// Copyright 2022 ModZero.
// SPDX-License-Identifier: AGPL-3.0-or-later
import "./app.css"; import "./app.css";
import App from "./App.svelte"; import App from "./App.svelte";
const app = new App({ const app = new App({
target: document.getElementById("app"), target: document.getElementById("app"),
}); });
export default app; export default app;

View File

@@ -6,6 +6,9 @@
"module": "ESNext", "module": "ESNext",
"resolveJsonModule": true, "resolveJsonModule": true,
"baseUrl": ".", "baseUrl": ".",
"paths": {
"@app/*": ["./src/*"]
},
/** /**
* Typecheck JS in `.svelte` and `.js` files by default. * Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS. * Disable checkJs if you'd like to use dynamic types in JS.

View File

@@ -1,11 +1,15 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte"; import { svelte } from "@sveltejs/vite-plugin-svelte";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
build: { build: {
write: true, write: true,
}, },
resolve: {
alias: {
"@app": "./src/",
},
},
}); });