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",
"problemMatcher": [],
"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
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
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.
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.
@@ -658,4 +658,4 @@ specific requirements.
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.
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(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use std::time::{Duration, Instant};
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
}
mod timers;
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![start_timer])
.plugin(timers::init())
.run(tauri::generate_context!())
.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">
import { onDestroy, onMount } from "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!",
});
}
import Timer from "./components/Timer.svelte";
</script>
<main>
<label>
Fire after
<input type="number" bind:value={seconds} />
</label>
<button on:click={start_timer}>Fire!</button>
<Timer />
</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 {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
@@ -26,7 +26,6 @@ a:hover {
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
@@ -37,45 +36,14 @@ h1 {
line-height: 1.1;
}
.card {
padding: 2em;
}
#app {
position: absolute;
max-width: 1280px;
margin: 0 auto;
margin: 0 0;
padding: 2rem;
text-align: center;
}
button {
border-radius: 8px;
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;
}
top: 0;
bottom: 0;
left: 0;
right: 0;
}

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 from "./App.svelte";
const app = new App({
target: document.getElementById("app"),
});
export default app;

View File

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

View File

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