External clock

This commit is contained in:
Douwe Osinga
2025-11-07 13:48:46 -05:00
parent 14d12a63d5
commit aaa3ffa626
4 changed files with 248 additions and 70 deletions
+6 -65
View File
@@ -116,56 +116,6 @@ async fn get_app(
}
}
const CLOCK_PRD: &str = r#"
# Digital Clock Widget
## Overview
A simple clock widget that displays the current time and date.
## Core Functionality
### Time Display
- Shows current time updated every second
- Supports both 12-hour (with AM/PM) and 24-hour format
- Uses monospace font for consistent digit width and easy readability
### Date Display
- Shows full date including day of week, month, day, and year
- Displays below the time in a smaller, secondary style
### Settings
- User can toggle between 12-hour and 24-hour time format
- Format preference persists across sessions
- Default: 24-hour format
## Visual Design
### Layout
- Vertically stacked: time on top, date below
- Content centered within widget bounds
- Clear visual hierarchy (time prominent, date secondary)
### Typography
- Time: Large, bold, monospace
- Date: Medium size, lighter color
- Settings toggle: Small, subtle, changes on hover
### Interaction
- Clickable settings control to toggle time format
- Visual feedback on hover for interactive elements
## Default Dimensions
- Width: 300px
- Height: 180px
- Resizable by user
## Technical Requirements
- Updates automatically every second
- Minimal resource usage
- Properly cleans up when widget is closed
- Uses system locale for date formatting
"#;
#[utoipa::path(
post,
path = "/apps",
@@ -188,21 +138,12 @@ async fn create_app(
) -> Result<(StatusCode, Json<SuccessResponse>), ErrorResponse> {
let manager = GooseAppsManager::new()?;
let app = if request.app.name == "" {
let clock_js = GOOSE_APP_ASSETS
.get_file("clock.js")
.ok_or_else(|| ErrorResponse::internal("clock.js not found"))?
.contents_utf8()
.ok_or_else(|| ErrorResponse::internal("clock.js is not valid UTF-8"))?;
GooseApp {
name: "Clock".to_string(),
description: Some("Example Clock app".to_string()),
width: Some(300),
height: Some(300),
resizable: Some(false),
js_implementation: clock_js.to_string(),
prd: CLOCK_PRD.parse().unwrap(),
}
let app = if request.app.name.is_empty() {
let clock_gapp = GOOSE_APP_ASSETS
.get_file("Clock.gapp")
.and_then(|f| f.contents_utf8())
.ok_or_else(|| ErrorResponse::internal("invalid or missing Clock.gapp"))?;
GooseApp::from_file_content(clock_gapp)?
} else {
request.app
};
+6 -1
View File
@@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{Error, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
@@ -24,6 +24,10 @@ impl GooseApp {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path)?;
Self::from_file_content(&content)
}
pub fn from_file_content(content: &str) -> Result<GooseApp, Error> {
let parts: Vec<&str> = content.splitn(3, Self::FRONTMATTER_DELIMITER).collect();
if parts.len() != 3 {
@@ -42,6 +46,7 @@ impl GooseApp {
pub fn to_file_content(&self) -> Result<String> {
let mut metadata = self.clone();
metadata.js_implementation = String::new();
metadata.prd = String::new();
let yaml_content = serde_yaml::to_string(&metadata)?;
Ok(format!(
"{}{}{}{}{}",
+223
View File
@@ -0,0 +1,223 @@
name: Clock
description: Swiss Railway Clock widget
width: 300
height: 300
resizable: false
---
class ClockWidget extends GooseWidget {
constructor(api) {
super(api);
this.animationFrame = null;
this.lastSecond = -1;
this.pauseUntil = 0;
}
css() {
return `
.clock-container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
#clockCanvas {
display: block;
}
`;
}
onMount() {
this.canvas = document.getElementById('clockCanvas');
this.ctx = this.canvas.getContext('2d');
this.animate();
}
onClose() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}
}
animate() {
this.drawClock();
this.animationFrame = requestAnimationFrame(() => this.animate());
}
drawClock() {
const now = Date.now();
const date = new Date(now);
const canvas = this.canvas;
const ctx = this.ctx;
const radius = canvas.width / 2;
const centerX = radius;
const centerY = radius;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(centerX, centerY, radius - 5, 0, 2 * Math.PI);
ctx.fillStyle = 'white';
ctx.fill();
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.stroke();
for (let i = 0; i < 60; i++) {
const angle = (i * 6) * Math.PI / 180;
const isHourMark = i % 5 === 0;
if (isHourMark) {
const x1 = centerX + Math.sin(angle) * (radius - 15);
const y1 = centerY - Math.cos(angle) * (radius - 15);
const x2 = centerX + Math.sin(angle) * (radius - 40);
const y2 = centerY - Math.cos(angle) * (radius - 40);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = 'black';
ctx.lineWidth = 8;
ctx.lineCap = 'butt';
ctx.stroke();
} else {
const x1 = centerX + Math.sin(angle) * (radius - 15);
const y1 = centerY - Math.cos(angle) * (radius - 15);
const x2 = centerX + Math.sin(angle) * (radius - 25);
const y2 = centerY - Math.cos(angle) * (radius - 25);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.lineCap = 'butt';
ctx.stroke();
}
}
ctx.font = '16px sans-serif';
ctx.fillStyle = 'black';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('GOOSE CLOCK', centerX, centerY - 65);
const seconds = date.getSeconds();
const milliseconds = date.getMilliseconds();
if (seconds === 0 && this.lastSecond === 59) {
this.pauseUntil = now + 1500;
}
this.lastSecond = seconds;
const hours = date.getHours() % 12;
const minutes = date.getMinutes();
const hourAngle = ((hours + minutes / 60) * 30) * Math.PI / 180;
this.drawHand(ctx, centerX, centerY, hourAngle, radius * 0.65, 11, 'black');
const minuteAngle = ((minutes + seconds / 60) * 6) * Math.PI / 180;
this.drawHand(ctx, centerX, centerY, minuteAngle, radius * 0.90, 8, 'black');
let secondAngle;
if (now < this.pauseUntil) {
secondAngle = 0;
} else {
secondAngle = ((seconds + milliseconds / 1000) * 6) * Math.PI / 180;
}
this.drawSecondHand(ctx, centerX, centerY, secondAngle, radius * 0.65);
ctx.beginPath();
ctx.arc(centerX, centerY, 6, 0, 2 * Math.PI);
ctx.fillStyle = '#d32f2f';
ctx.fill();
}
drawHand(ctx, x, y, angle, length, width, color) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.beginPath();
ctx.rect(-width/2, -length, width, length + 20);
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
}
drawSecondHand(ctx, x, y, angle, length) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, length * 0.2);
ctx.lineTo(0, -length);
ctx.strokeStyle = '#d32f2f';
ctx.lineWidth = 2.5;
ctx.lineCap = 'butt';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -length, 10, 0, 2 * Math.PI);
ctx.fillStyle = '#d32f2f';
ctx.fill();
ctx.restore();
}
render() {
return `
<div class="clock-container">
<canvas id="clockCanvas" width="300" height="300"></canvas>
</div>
`;
}
getDefaultSize() {
return { width: 300, height: 300 };
}
}
---
# Swiss Railway Clock Widget
## Overview
An analog clock widget inspired by the iconic Swiss railway clock (Hans Hilfiker design).
## Core Functionality
### Time Display
- Analog clock face with hour, minute, and second hands
- Second hand sweeps smoothly in small increments (10 updates/second)
- Distinctive "pause and jump" behavior: second hand pauses at 12 o'clock for ~1.5 seconds before jumping to the next minute
- Hour and minute hands update continuously based on current time
- Clean white face with simple black hour markers
### Swiss Clock Behavior
- Second hand: smooth sweep with characteristic pause at top
- During pause: hour and minute hands continue to move
- After pause: second hand jumps to correct position and resumes sweep
- Mimics the synchronization behavior of Swiss railway station clocks
### Visual Design
- Circular white clock face
- Black outline circle
- Simple black tick marks at hour positions
- Red lollipop-style second hand (round tip)
- Black tapered hour and minute hands
- Centered on widget canvas
## Default Dimensions
- Width: 300px
- Height: 300px
- Not resizable (maintains aspect ratio)
## Technical Requirements
- Uses HTML5 canvas for rendering
- Updates 10 times per second for smooth animation
- Properly implements Swiss railway clock timing behavior
- Cleans up animation loop when widget closes
+13 -4
View File
@@ -44,10 +44,13 @@ export function getContainerHtml(gapp: GooseApp): string {
}
export async function launchGooseApp(gapp: GooseApp): Promise<void> {
const desiredContentWidth = gapp.width || 800;
const desiredContentHeight = gapp.height || 600;
const appWindow = new BrowserWindow({
title: gapp.name,
width: gapp.width || 800,
height: gapp.height || 600,
width: desiredContentWidth,
height: desiredContentHeight,
resizable: gapp.resizable ?? true,
webPreferences: {
nodeIntegration: false,
@@ -56,10 +59,16 @@ export async function launchGooseApp(gapp: GooseApp): Promise<void> {
},
});
const html = getContainerHtml(gapp);
const [currentContentWidth, currentContentHeight] = appWindow.getContentSize();
const [currentWindowWidth, currentWindowHeight] = appWindow.getSize();
const frameWidth = currentWindowWidth - currentContentWidth;
const frameHeight = currentWindowHeight - currentContentHeight;
appWindow.setSize(desiredContentWidth + frameWidth, desiredContentHeight + frameHeight);
const html = getContainerHtml(gapp);
await appWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
appWindow.show();
appWindow.show();
}