Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: CI

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
manifest:
name: Manifest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The same checks `omarchy plugin validate` runs: schema version, required
# fields, a well-formed id outside the reserved namespace, entry points
# that are safe relative paths and exist, and no symlinks in the tree.
- name: Validate manifest.json
run: |
set -euo pipefail
jq -e '.schemaVersion == 1' manifest.json
for field in id name version kinds entryPoints; do
jq -e --arg f "$field" 'has($f)' manifest.json >/dev/null
done
jq -e '.id | test("^[A-Za-z0-9][A-Za-z0-9._-]*$")' manifest.json
jq -e '.id | (startswith("omarchy.") | not) and (contains("..") | not)' manifest.json
jq -e '(.kinds | type) == "array" and (.kinds | length) > 0' manifest.json
jq -e '(.entryPoints | type) == "object"' manifest.json
jq -e '.barWidget.defaultSection as $s | $s == null or (["left","center","right"] | index($s)) != null' manifest.json
jq -r '.entryPoints[]' manifest.json | while IFS= read -r ep; do
case "$ep" in
/*|*..*) echo "unsafe entry point: $ep"; exit 1 ;;
esac
test -f "$ep" || { echo "missing entry point: $ep"; exit 1; }
done
for kind in bar bar-widget menu overlay panel service; do
jq -e --arg k "$kind" '(.kinds | index($k)) == null' manifest.json >/dev/null && continue
key="$kind"; [[ $kind == bar-widget ]] && key=barWidget
jq -e --arg k "$key" '.entryPoints | has($k)' manifest.json >/dev/null \
|| { echo "kind '$kind' has no entryPoints.$key"; exit 1; }
done
link=$(find . -name .git -prune -o -type l -print -quit)
[[ -z $link ]] || { echo "symlink in plugin tree: $link"; exit 1; }
- name: Data files parse
run: |
jq -e 'type == "array" and length > 0' cities.json >/dev/null
jq -e 'type == "array" and length > 0' world.json >/dev/null
- name: Python compiles
run: python3 -m py_compile worldclock-data.py tests/currency_check.py

test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Install Qt test runner and iso-codes
run: |
sudo apt-get update
sudo apt-get install --yes \
iso-codes \
qt6-declarative-dev-tools \
qml6-module-qtqml-workerscript \
qml6-module-qtquick \
qml6-module-qtquick-window \
qml6-module-qttest
# --offline keeps the run deterministic: the live-rate and Open-Meteo
# cross-checks are worth running by hand but should not fail a PR when
# a third-party feed hiccups.
- name: Run tests
run: ./tests/run --offline
84 changes: 84 additions & 0 deletions Arc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Laying a line of text along a shallow circular arc.
//
// Kept out of the QML so the geometry can be tested without a window. The
// caller measures each character (advance widths from FontMetrics) and gets
// back where to put it and how far to turn it; nothing here knows about
// fonts, items or colours.
//
// Parameterised by `rise` - how far the ends of the line sit above (or below)
// its middle - rather than by a radius. A radius means nothing at a glance
// and its effect changes with the length of the string: the same 1600px
// circle bends a short line barely at all and a long one visibly. A rise in
// pixels is the thing being judged by eye, and it holds steady as the text
// changes underneath it, which this line does every second.

.pragma library

// widths: advance width of each character, in order.
// rise: pixels the ends are displaced from the middle. 0 is a flat line.
// smile: true bends the ends up (a shallow U), false bends them down.
//
// Returns { width, height, chars: [{ x, y, rotation }] }, where x/y are the
// top-left of each character's box in the returned bounding size, and
// rotation is degrees about the character's own centre.
function layout(widths, rise, smile) {
var chars = []
var total = 0
var i
for (i = 0; i < widths.length; i++) total += widths[i]

// A flat line is not a special case worth a separate code path in the
// caller, so it is one here: radius would divide by zero.
if (total <= 0 || rise <= 0) {
var x = 0
for (i = 0; i < widths.length; i++) {
chars.push({ x: x, y: 0, rotation: 0 })
x += widths[i]
}
return { width: total, height: 0, chars: chars }
}

// Past a quarter of the width the arc stops reading as a bent line and
// starts reading as a circle with a word on it. Nothing sane comes near
// this; it only keeps the trig in range.
var sag = Math.min(rise, total / 4)

// Sagitta of a circular segment: s = R(1 - cos(w/2R)), and for shallow
// arcs R = w^2/8s. The approximation is what sets the radius; the angles
// below are then exact for that radius, so the ends land within a fraction
// of a pixel of the rise that was asked for.
var radius = (total * total) / (8 * sag)
var halfAngle = total / (2 * radius)
var maxDrop = radius * (1 - Math.cos(halfAngle))
var chordWidth = 2 * radius * Math.sin(halfAngle)

var travelled = 0
var left = 0, right = chordWidth
for (i = 0; i < widths.length; i++) {
var w = widths[i]
// Arc length from the middle of the line to the middle of this character.
var a = (travelled + w / 2 - total / 2) / radius
var drop = radius * (1 - Math.cos(a))
var x = chordWidth / 2 + radius * Math.sin(a) - w / 2
// y grows downward, so a smile puts the middle of the line at the bottom
// of the box and the ends at the top.
chars.push({
x: x,
y: smile ? maxDrop - drop : drop,
rotation: (smile ? -a : a) * 180 / Math.PI
})
left = Math.min(left, x)
right = Math.max(right, x + w)
travelled += w
}

// The end characters straddle the ends of the chord, so their boxes hang
// outside it. Reported width is what actually has to be reserved, and x
// starts at zero, so a caller can centre the result on its own width
// without the first character falling off the left of the panel. (Turning
// each box about its centre widens it a little further, by an amount that
// depends on the glyph height this file does not know; the caller pads.)
for (i = 0; i < chars.length; i++) chars[i].x -= left

return { width: right - left, height: maxDrop, chars: chars }
}
122 changes: 122 additions & 0 deletions ArcText.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import QtQuick
import qs.Commons
import "Arc.js" as Arc

// One line of text bent onto a shallow arc, each character turned to follow
// it.
//
// Not a Canvas: the panel's text is real Text items everywhere else, and a
// canvas would render this one line through a different path - its own font
// string, its own hinting, its own idea of the pixel grid - so it would sit
// visibly apart from the title above it at the same size. A character per
// Text item costs a handful of items on a line of thirty and keeps the
// rendering identical to its neighbours.
//
// Styling arrives as `runs` - {text, color, underline} - rather than as
// markup, because there is no per-character position inside a StyledText to
// place a glyph from. The flat version of the same line builds its markup
// from the same runs, so the two cannot drift apart.
//
// There is no eliding. The sentence is short, the panel's width is fixed, and
// an elide on an arc would have to decide what a truncated curve looks like;
// if the text ever outgrows the panel, the flat line is the one that handles
// it.
Item {
id: root

// [{ text: string, color: string ("" inherits), underline: bool }]
property var runs: []

// How far the ends sit off the middle. Small: this is meant to be noticed
// as a shape, not read as a curve.
property real rise: 6
// Ends up (a shallow smile) or ends down (an arch over what is below it).
property bool smile: true

property string fontFamily: Style.font.family
property int pixelSize: Style.font.caption
property int weight: Font.Normal
property color color: Color.foreground

FontMetrics {
id: metrics
font.family: root.fontFamily
font.pixelSize: root.pixelSize
font.weight: root.weight
}

// Runs flattened to characters, each carrying the run's styling with it.
readonly property var glyphs: {
var out = []
for (var i = 0; i < runs.length; i++) {
var run = runs[i]
var text = String(run && run.text !== undefined ? run.text : "")
for (var j = 0; j < text.length; j++)
out.push({ ch: text.charAt(j),
color: run.color !== undefined ? String(run.color) : "",
underline: run.underline === true })
}
return out
}

readonly property var placed: {
// FontMetrics does not announce itself as a dependency of advanceWidth,
// so name the font here or a theme or size change leaves the old layout
// in place under new glyphs.
var _ = metrics.font.family + metrics.font.pixelSize + metrics.font.weight
var widths = []
for (var i = 0; i < glyphs.length; i++) widths.push(metrics.advanceWidth(glyphs[i].ch))
return Arc.layout(widths, rise, smile)
}

// Turning a box about its centre pushes its corners out by up to half its
// diagonal, and the end characters are the most turned; half a line height
// either side covers it at any rise this is used at.
readonly property real slack: Math.round(metrics.height / 2)

implicitWidth: Math.ceil(placed.width) + 2 * slack
implicitHeight: Math.ceil(placed.height + metrics.height)

// The arc is centred on whatever width it is given, so it stays centred in
// a panel that is wider than the sentence.
readonly property real originX: (width - placed.width) / 2

Repeater {
model: root.glyphs.length

Text {
required property int index
// The model is a count, and the count changes a beat before the arrays
// behind it do - every second, as the clock ticks the sentence's length
// around. Without a fallback that beat is a torrent of TypeErrors from
// delegates reaching past the end of the old layout.
readonly property var glyph:
root.glyphs[index] || { ch: "", color: "", underline: false }
readonly property var spot:
root.placed.chars[index] || { x: 0, y: 0, rotation: 0 }

// The box is exactly one advance wide, so the arc's arithmetic and the
// glyph agree about where the character's middle is; left-aligned text
// in a wider box would drift off the curve.
width: metrics.advanceWidth(glyph.ch)
height: metrics.height
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter

x: root.originX + spot.x
y: spot.y
rotation: spot.rotation
transformOrigin: Item.Center

text: glyph.ch
color: glyph.color !== "" ? glyph.color : root.color
font.family: root.fontFamily
font.pixelSize: root.pixelSize
font.weight: root.weight
// Per character, so the rule follows the curve in short segments that
// meet at the character boundaries rather than cutting the chord.
font.underline: glyph.underline
renderType: Text.QtRendering // native rendering ignores the rotation
}
}
}
46 changes: 46 additions & 0 deletions Chip.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import QtQuick
import qs.Commons

// A small dark label that floats over the row: sunrise, sunset, the moon's
// phase. Always an answer to something just clicked, never part of the resting
// state of the panel.
//
// Its two colours are literal rather than theme roles. This is a tooltip, and
// tooltips are inverted everywhere - a dark chip with light text is the same
// shape in a light theme as in a dark one, while the theme's own foreground
// would put dark text on a dark chip half the time.
//
// It is opaque on purpose. It sits over the date line and the offset, and text
// over text is unreadable whatever the two colours are.
Rectangle {
id: chip

property string label: ""
property string fontFamily: Style.font.family
property bool shown: false

// Where the chip wants to be centred, in the parent's coordinates. Held
// inside the parent by the binding below: a sunrise a few minutes after
// midnight would otherwise hang its chip off the left edge of the row.
property real centreX: 0

width: chipText.implicitWidth + Style.space(10)
height: chipText.implicitHeight + Style.space(5)
radius: Style.space(3)
color: "#0B0D11"

x: Math.round(Math.max(0, Math.min(parent.width - width, centreX - width / 2)))

opacity: shown ? 1 : 0
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: 120 } }

Text {
id: chipText
anchors.centerIn: parent
text: chip.label
color: "#EDE7DA"
font.family: chip.fontFamily
font.pixelSize: Style.font.caption
}
}
Loading
Loading