Compare commits

...
6 Commits
15 changed files with 2241 additions and 8 deletions

No files matched your search

-2
View File
@@ -5,7 +5,6 @@
./user/helix.nix
./user/hyprland.nix
./user/hyprlock.nix
../user/sweetbread/modules/lucid.nix
./user/mako.nix
./user/neofetch.nix
./user/qt.nix
@@ -14,7 +13,6 @@
./user/sops.nix
./user/ssh.nix
./user/walker.nix
./user/wob.nix
./user/yazi.nix
./user/zsh.nix
+310
View File
@@ -0,0 +1,310 @@
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
// reminders: what the clock rings for, what the calendar marks, and what the
// widgets and the lock screen list as coming up
Singleton {
id: root
// [{ id, year, month, day, hour, minute, name, repeat }]; repeat is
// "" | "daily" | "weekdays" | "weekly", counted from the stored date
property alias items: store.items
property int tick: 0
// reminders due and not yet dealt with, oldest first
property var ringing: []
readonly property var firing: root.ringing.length > 0 ? root.ringing[0] : null
property var snoozedUntil: ({})
property var dismissedAt: ({})
property string checkedDay: ""
// when this copy of the shell started, so a reload does not ring out loud
readonly property real bornAt: Date.now()
// changes once a minute; what the countdown text needs, instead of every tick
property string minuteKey: ""
readonly property var next: {
root.tick;
CalendarSource.tick;
return root.upcoming(1)[0] || null;
}
readonly property bool soon: {
var n = root.next;
return n !== null && n.at.getTime() - Loc.nowMs() <= 7 * 86400000;
}
readonly property var repeatOptions: [
{ "key": "", "label": "Once" },
{ "key": "daily", "label": "Daily" },
{ "key": "weekdays", "label": "Weekdays" },
{ "key": "weekly", "label": "Weekly" }
]
function dayStart(y, m, d) {
return new Date(y, m, d, 0, 0, 0, 0);
}
// does this reminder happen on the given day?
function occursOn(r, y, m, d) {
var day = root.dayStart(y, m, d);
var start = root.dayStart(r.year, r.month, r.day);
if (day < start)
return false;
switch (r.repeat || "") {
case "daily":
return true;
case "weekdays":
return day.getDay() !== 0 && day.getDay() !== 6;
case "weekly":
return day.getDay() === start.getDay();
}
return day.getTime() === start.getTime();
}
// the next time a reminder rings at or after `from`
// `next` re-reads this every second for every reminder, so it must not walk
// the calendar: a one-off happens on exactly its own date, and daily,
// weekdays and weekly all come round inside a week of whichever is later,
// today or the date the repeat starts from
function nextAt(r, from) {
if (!r.repeat) {
var at = new Date(r.year, r.month, r.day, r.hour, r.minute, 0, 0);
return at >= from ? at : null;
}
var fromDay = root.dayStart(from.getFullYear(), from.getMonth(), from.getDate());
var startDay = root.dayStart(r.year, r.month, r.day);
var probe = startDay > fromDay ? startDay : fromDay;
for (var i = 0; i < 8; i++) {
if (root.occursOn(r, probe.getFullYear(), probe.getMonth(), probe.getDate())) {
var a = new Date(probe.getFullYear(), probe.getMonth(), probe.getDate(), r.hour, r.minute, 0, 0);
if (a >= from)
return a;
}
probe.setDate(probe.getDate() + 1);
}
return null;
}
function upcoming(limit) {
var now = Loc.now();
var out = [];
for (var i = 0; i < root.items.length; i++) {
var at = root.nextAt(root.items[i], now);
if (at)
out.push({ "item": root.items[i], "at": at });
}
out = out.concat(CalendarSource.upcoming());
out.sort((a, b) => {
return a.at - b.at;
});
return out.slice(0, limit || out.length);
}
function forDay(y, m, d) {
var local = root.items.filter((r) => {
return root.occursOn(r, y, m, d);
});
var fromThunderbird = CalendarSource.forDay(y, m, d).map((event) => CalendarSource.asAgendaItem(event));
return local.concat(fromThunderbird).sort((a, b) => {
return (a.hour * 60 + a.minute) - (b.hour * 60 + b.minute);
});
}
function hasOn(y, m, d) {
if (CalendarSource.hasOn(y, m, d))
return true;
for (var i = 0; i < root.items.length; i++) {
if (root.occursOn(root.items[i], y, m, d))
return true;
}
return false;
}
function add(y, m, d, hour, minute, name, repeat) {
root.items = root.items.concat([{
"id": Date.now() + "-" + Math.random().toString(36).slice(2, 7),
"year": y,
"month": m,
"day": d,
"hour": hour,
"minute": minute,
"name": name,
"repeat": repeat || ""
}]);
root.tick++;
}
function remove(id) {
root.items = root.items.filter((r) => {
return r.id !== id;
});
root.ringing = root.ringing.filter((r) => {
return r.id !== id;
});
root.tick++;
}
function timeText(r) {
if (!r)
return "";
if (Prefs.clock24h)
return String(r.hour).padStart(2, "0") + ":" + String(r.minute).padStart(2, "0");
var h = r.hour % 12 === 0 ? 12 : r.hour % 12;
return h + ":" + String(r.minute).padStart(2, "0") + " " + (r.hour < 12 ? "AM" : "PM");
}
function untilText(at) {
if (!at)
return "";
var mins = Math.round((at - Loc.now()) / 60000);
if (mins < 1)
return "now";
if (mins < 60)
return "in " + mins + " min";
var hrs = Math.round(mins / 60);
if (hrs < 24)
return "in " + hrs + (hrs === 1 ? " hour" : " hours");
var days = Math.round(hrs / 24);
return days === 1 ? "tomorrow" : "in " + days + " days";
}
function repeatText(r) {
switch (r.repeat || "") {
case "daily":
return "Every day";
case "weekdays":
return "Weekdays";
case "weekly":
return "Every " + root.dayStart(r.year, r.month, r.day).toLocaleDateString(Qt.locale(), "dddd");
}
return "";
}
function snooze(id, minutes) {
var m = Object.assign({}, root.snoozedUntil);
m[id] = Loc.nowMs() + minutes * 60000;
root.snoozedUntil = m;
root.ringing = root.ringing.filter((q) => {
return q.id !== id;
});
}
// a one-off goes once it has rung; a repeating one waits for its next turn
function dismiss(id) {
var m = Object.assign({}, root.dismissedAt);
m[id] = Loc.nowMs();
root.dismissedAt = m;
root.ringing = root.ringing.filter((q) => {
return q.id !== id;
});
var r = root.items.find((x) => {
return x.id === id;
});
if (r && !r.repeat)
root.remove(id);
}
function prune(now) {
var today = root.dayStart(now.getFullYear(), now.getMonth(), now.getDate());
var kept = root.items.filter((r) => {
return r.repeat || root.dayStart(r.year, r.month, r.day) >= today;
});
if (kept.length !== root.items.length)
root.items = kept;
}
function check() {
var now = Loc.now();
var key = now.getFullYear() + "-" + now.getMonth() + "-" + now.getDate();
if (root.checkedDay !== key) {
root.prune(now);
root.checkedDay = key;
}
var mkey = key + "-" + now.getHours() + "-" + now.getMinutes();
if (root.minuteKey !== mkey)
root.minuteKey = mkey;
var fresh = false;
for (var i = 0; i < root.items.length; i++) {
var r = root.items[i];
if (root.ringing.some((q) => {
return q.id === r.id;
}))
continue;
var until = root.snoozedUntil[r.id];
if (until && now.getTime() < until)
continue;
// today's occurrence, if there is one and it has come round
if (!root.occursOn(r, now.getFullYear(), now.getMonth(), now.getDate()))
continue;
var due = new Date(now.getFullYear(), now.getMonth(), now.getDate(), r.hour, r.minute, 0, 0);
if (now < due)
continue;
var last = root.dismissedAt[r.id];
if (last && last >= due.getTime())
continue;
// an hour late is too late to ring
if (!until && now.getTime() - due.getTime() > 3600000)
continue;
root.ringing = root.ringing.concat([r]);
// a reload forgets dismissals, so one already overdue as the shell
// starts rings without a sound; after that, one that came due while
// the machine slept still gets it
if (until || Date.now() - root.bornAt > 5000 || now.getTime() - due.getTime() < 60000)
fresh = true;
}
if (fresh && Prefs.reminderSound)
Sounds.play("reminder", 1);
root.tick++;
}
Timer {
interval: 1000
repeat: true
running: true
onTriggered: root.check()
}
Timer {
id: saveDebounce
interval: 300
onTriggered: file.writeAdapter()
}
FileView {
id: file
path: Quickshell.env("HOME") + "/.config/quickshell/lucidbar/clock_reminders.json"
blockLoading: true
onAdapterUpdated: saveDebounce.restart()
adapter: JsonAdapter {
id: store
property var items: []
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs
pragma Singleton
// Read-only events already synchronized by Thunderbird. This deliberately does
// not connect to DAV or store credentials; Thunderbird remains the DAV client.
Singleton {
id: source
property var events: []
property int tick: 0
property string error: ""
property real updatedAt: 0
function refresh() {
if (reader.running)
return;
reader.command = ["python3", Quickshell.env("HOME") + "/.config/quickshell/thunderbird-calendar.py"];
reader.running = true;
}
function forDay(year, month, day) {
source.tick;
return source.events.filter((event) => {
const date = new Date(event.startMs);
return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day;
});
}
function upcoming(limit) {
source.tick;
const now = Loc.nowMs();
return source.events.filter((event) => event.startMs >= now).slice(0, limit || source.events.length).map((event) => ({
"item": source.asAgendaItem(event),
"at": new Date(event.startMs)
}));
}
function hasOn(year, month, day) {
return source.forDay(year, month, day).length > 0;
}
function asAgendaItem(event) {
const date = new Date(event.startMs);
return {
"id": "thunderbird:" + event.id,
"name": event.name,
"calendar": event.calendar,
"source": "thunderbird",
"startMs": event.startMs,
"endMs": event.endMs,
"hour": date.getHours(),
"minute": date.getMinutes(),
"repeat": ""
};
}
Process {
id: reader
command: []
stdout: StdioCollector {
onStreamFinished: {
if (this.text.trim() === "")
return;
try {
const result = JSON.parse(this.text);
if (!result.error) {
source.events = result.events || [];
source.error = "";
source.updatedAt = Date.now();
source.tick++;
} else {
source.error = result.error;
}
} catch (e) {
source.error = "Could not read Thunderbird calendar cache";
}
}
}
onExited: (code) => {
if (code !== 0 && source.error === "")
source.error = "Could not read Thunderbird calendar cache";
}
}
Timer {
interval: 300000
repeat: true
running: true
triggeredOnStart: true
onTriggered: source.refresh()
}
}
+536
View File
@@ -0,0 +1,536 @@
import QtQuick
import qs
import qs.lucidui
// the month on the left, the picked day's reminders and a way to add one on the right
Item {
id: page
property var host: null
property var selected: {
const n = Loc.now();
return { "year": n.getFullYear(), "month": n.getMonth(), "day": n.getDate() };
}
readonly property date selectedDate: new Date(page.selected.year, page.selected.month, page.selected.day)
readonly property var dayItems: {
Agenda.checkedDay;
return Agenda.forDay(page.selected.year, page.selected.month, page.selected.day);
}
property int newHour: 9
property int newMinute: 0
// the hour is freshTime's next-hour guess, not one anybody chose
property bool hourGuessed: false
// am/pm was set by hand, so a typed hour keeps to it
property bool halfPicked: false
property string newRepeat: ""
readonly property bool pm: page.newHour >= 12
readonly property int shownHour: Prefs.clock24h ? page.newHour : (page.newHour % 12 === 0 ? 12 : page.newHour % 12)
// a one-off set before now would never ring
readonly property bool past: {
Agenda.minuteKey;
return page.newRepeat === "" && page.gone(page.newHour, page.newMinute);
}
readonly property bool shown: page.visible && (!page.host || page.host.expanded)
onShownChanged: if (page.shown)
page.freshTime()
function selectToday() {
const n = Loc.now();
page.selected = { "year": n.getFullYear(), "month": n.getMonth(), "day": n.getDate() };
month.goToday();
page.freshTime();
}
// earlier than the minute we are in, on the picked day
function gone(hour, minute) {
const n = Loc.now();
return new Date(page.selected.year, page.selected.month, page.selected.day, hour, minute) < new Date(n.getFullYear(), n.getMonth(), n.getDate(), n.getHours(), n.getMinutes());
}
function isToday() {
return page.gone(0, 0) && !page.gone(23, 59);
}
// every opening of the clock starts on today, unless a reminder is half written
function startVisit() {
if (nameField.text !== "")
return ;
const n = Loc.now();
page.selected = { "year": n.getFullYear(), "month": n.getMonth(), "day": n.getDate() };
if (!month.isCurrent)
month.goToday();
page.freshTime();
}
// 9:00, or the next hour once 9:00 has gone today; a time already set for a name stays
function freshTime() {
if (nameField.text !== "")
return ;
page.newHour = 9;
page.newMinute = 0;
page.hourGuessed = false;
page.halfPicked = false;
const next = Loc.now().getHours() + 1;
if (page.gone(9, 0) && next < 24 && !page.gone(next, 0)) {
page.newHour = next;
page.hourGuessed = true;
}
}
// a typed 12-hour time means the half still to come: 12:04 at noon is pm
function preferUpcoming() {
if (Prefs.clock24h || !page.past)
return ;
const flipped = (page.newHour + 12) % 24;
if (!page.gone(flipped, page.newMinute))
page.newHour = flipped;
}
function focusName() {
nameField.input.forceActiveFocus();
}
function submit() {
const name = nameField.text.trim();
if (name === "" || page.past)
return ;
Agenda.add(page.selected.year, page.selected.month, page.selected.day, page.newHour, page.newMinute, name, page.newRepeat);
nameField.text = "";
page.newRepeat = "";
}
function step(minutes) {
page.hourGuessed = false;
let t = (page.newHour * 60 + page.newMinute + minutes + 1440) % 1440;
page.newHour = Math.floor(t / 60);
page.newMinute = t % 60;
}
// typed hours are 0-23, or 1-12 next to the am/pm toggle; on today a
// 12-hour one is its next turn, not the half the guessed hour left behind
function typeHour(h) {
page.hourGuessed = false;
if (Prefs.clock24h) {
page.newHour = Math.min(23, h);
} else if (page.isToday() && !page.halfPicked) {
// the minutes are still to come, so only an hour wholly gone is past
const h12 = Math.min(12, h) % 12;
page.newHour = page.gone(h12, 59) ? h12 + 12 : h12;
} else {
page.newHour = Math.min(12, h) % 12 + (page.pm ? 12 : 0);
page.preferUpcoming();
}
}
// minutes typed under the guessed hour mean the next time the clock shows
// them: at 12:19, 20 is 12:20 rather than 1:20
function typeMinute(m) {
page.newMinute = Math.min(59, m);
if (page.hourGuessed && page.isToday()) {
const h = Loc.now().getHours();
if (!page.gone(h, page.newMinute))
page.newHour = h;
else if (h + 1 < 24)
page.newHour = h + 1;
}
page.preferUpcoming();
}
function setPm(wantPm) {
page.hourGuessed = false;
page.halfPicked = true;
page.newHour = page.newHour % 12 + (wantPm ? 12 : 0);
}
// two digits you type into, each holding its own value until focus leaves
component TimeDigit: Item {
id: digit
property int value: 0
property int span: 60
property Item nextDigit: null
readonly property bool typing: field.activeFocus
signal typed(int v)
signal meridiem(bool wantPm)
signal accepted()
function show() {
field.text = String(digit.value).padStart(2, "0");
}
function take() {
field.forceActiveFocus();
field.selectAll();
}
function nudge(by) {
digit.typed((digit.value + by + digit.span) % digit.span);
digit.show();
}
width: Theme.dp(30)
height: Theme.dp(30)
onValueChanged: if (!field.activeFocus)
digit.show()
Component.onCompleted: digit.show()
TextInput {
id: field
anchors.fill: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
maximumLength: 2
inputMethodHints: Qt.ImhDigitsOnly
selectByMouse: true
color: Theme.text
selectionColor: Theme.alpha(Theme.primary, 0.35)
selectedTextColor: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.typeSize("labelLarge")
font.variableAxes: Theme.axes(Theme.typeSize("labelLarge"), 640, 0)
font.features: ({
"tnum": 1
})
onTextEdited: {
if (field.text !== "")
digit.typed(parseInt(field.text, 10));
if (field.text.length === 2 && digit.nextDigit)
digit.nextDigit.take();
}
onActiveFocusChanged: {
if (field.activeFocus)
Qt.callLater(field.selectAll);
else
digit.show();
}
onAccepted: digit.accepted()
Keys.onUpPressed: digit.nudge(1)
Keys.onDownPressed: digit.nudge(-1)
Keys.onTabPressed: digit.nextDigit ? digit.nextDigit.take() : digit.show()
Keys.onPressed: (e) => {
const c = e.text.toLowerCase();
if (c === "a" || c === "p")
digit.meridiem(c === "p");
}
validator: RegularExpressionValidator {
regularExpression: /[0-9]{0,2}/
}
}
Rectangle {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - Theme.dp(6)
height: Theme.dp(2)
radius: 1
color: digit.typing ? Theme.primary : Theme.alpha(Theme.subtext, 0.4)
Behavior on color {
ColorAnimation {
duration: Theme.durFastEffects
}
}
}
}
implicitHeight: Theme.dp(404)
Connections {
function onExpandedChanged() {
if (page.host.expanded)
page.startVisit();
}
target: page.host
}
Rectangle {
id: monthCard
width: Theme.dp(312)
height: parent.height
radius: Theme.shapeXl
color: Theme.withBlur(Theme.surfaceHigh)
MonthView {
id: month
anchors.centerIn: parent
cell: Theme.dp(40)
selected: page.selected
onPicked: (y, m, d) => {
page.selected = { "year": y, "month": m, "day": d };
if (m !== month.monthIndex)
month.page(y * 12 + m > month.year * 12 + month.monthIndex ? 1 : -1);
}
}
}
Rectangle {
anchors.left: monthCard.right
anchors.leftMargin: Theme.dp(12)
anchors.right: parent.right
height: parent.height
radius: Theme.shapeXl
color: Theme.withBlur(Theme.surfaceHigh)
Item {
anchors.fill: parent
anchors.margins: Theme.dp(18)
Column {
id: dayHead
width: parent.width
spacing: 0
LText {
role: "titleLarge"
weight: 560
text: page.selectedDate.toLocaleDateString(Qt.locale(), "dddd")
}
LText {
role: "bodyMedium"
color: Theme.subtext
text: page.selectedDate.toLocaleDateString(Qt.locale(), "d MMMM yyyy") + (page.dayItems.length ? " · " + page.dayItems.length + (page.dayItems.length === 1 ? " reminder" : " reminders") : "")
}
}
Flickable {
id: list
anchors.top: dayHead.bottom
anchors.topMargin: Theme.dp(12)
anchors.bottom: form.top
anchors.bottomMargin: Theme.dp(12)
width: parent.width
contentHeight: items.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: items
width: list.width
spacing: Theme.dp(2)
Repeater {
model: page.dayItems
ListItem {
id: row
required property var modelData
required property int index
width: items.width
first: row.index === 0
last: row.index === page.dayItems.length - 1
containerColor: Theme.withBlur(Theme.surfaceHighest)
clickable: false
headline: row.modelData.name
supporting: Agenda.timeText(row.modelData) + (row.modelData.repeat ? " · " + Agenda.repeatText(row.modelData) : "") + (row.modelData.source === "thunderbird" ? " · " + row.modelData.calendar : "")
icon: row.modelData.source === "thunderbird" ? "event" : (row.modelData.repeat ? "repeat" : "notifications")
iconColor: Theme.primary
trailing: [
IconButton {
visible: row.modelData.source !== "thunderbird"
icon: "delete"
size: "xs"
onClicked: Agenda.remove(row.modelData.id)
}
]
}
}
}
Column {
visible: page.dayItems.length === 0
anchors.horizontalCenter: parent.horizontalCenter
y: Theme.dp(24)
spacing: Theme.dp(8)
Icon {
anchors.horizontalCenter: parent.horizontalCenter
name: "event_available"
size: Theme.dp(32)
color: Theme.subtextDim
}
LText {
anchors.horizontalCenter: parent.horizontalCenter
role: "bodyMedium"
color: Theme.subtext
text: "A free day"
}
}
}
Column {
id: form
anchors.bottom: parent.bottom
width: parent.width
spacing: Theme.dp(10)
TextField {
id: nameField
width: parent.width
label: "New reminder"
placeholder: "What should it say?"
supporting: page.past && nameField.text.trim() !== "" ? (page.gone(0, 0) && !page.gone(23, 59) ? Agenda.timeText({ "hour": page.newHour, "minute": page.newMinute }) + " has already gone by today" : "That day has already gone by") : ""
containerColor: Theme.withBlur(Theme.surfaceHighest)
onAccepted: page.submit()
}
ButtonGroup {
width: parent.width
size: "xs"
options: Agenda.repeatOptions
current: page.newRepeat
onPicked: (k) => {
return page.newRepeat = k;
}
}
Row {
width: parent.width
spacing: Theme.dp(8)
Rectangle {
id: timeBox
width: timeRow.implicitWidth + Theme.dp(20)
height: Theme.dp(36)
radius: Theme.dp(18)
color: Theme.withBlur(Theme.surfaceHighest)
Row {
id: timeRow
anchors.centerIn: parent
spacing: Theme.dp(2)
TimeDigit {
id: hourDigit
anchors.verticalCenter: parent.verticalCenter
value: page.shownHour
span: Prefs.clock24h ? 24 : 12
nextDigit: minuteDigit
onTyped: (v) => {
return page.typeHour(v);
}
onMeridiem: (wantPm) => {
return page.setPm(wantPm);
}
onAccepted: page.submit()
}
LText {
anchors.verticalCenter: parent.verticalCenter
role: "labelLarge"
weight: 640
color: Theme.subtext
text: ":"
}
TimeDigit {
id: minuteDigit
anchors.verticalCenter: parent.verticalCenter
value: page.newMinute
span: 60
onTyped: (v) => {
return page.typeMinute(v);
}
onMeridiem: (wantPm) => {
return page.setPm(wantPm);
}
onAccepted: page.submit()
}
Item {
visible: !Prefs.clock24h
anchors.verticalCenter: parent.verticalCenter
width: Theme.dp(32)
height: Theme.dp(30)
LText {
anchors.centerIn: parent
role: "labelMedium"
weight: 640
color: Theme.primary
text: page.pm ? "PM" : "AM"
}
StateLayer {
radius: Theme.rad(8)
onClicked: page.setPm(!page.pm)
}
}
}
// the wheel moves a minute at a time
WheelHandler {
onWheel: (e) => {
return page.step(e.angleDelta.y > 0 ? 1 : -1);
}
}
}
Item {
width: parent.width - timeBox.width - addBtn.width - Theme.dp(16)
height: 1
}
Button {
id: addBtn
variant: "filled"
size: "xs"
icon: "add"
text: "Add"
disabled: nameField.text.trim() === "" || page.past
onClicked: page.submit()
}
}
}
}
}
}
+611
View File
@@ -0,0 +1,611 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import qs
Variants {
id: variants
model: Quickshell.screens
Scope {
id: unit
required property var modelData
PanelWindow {
id: layer
PaletteFade {
active: layer.visible
}
readonly property bool suppressed: Prefs.widgetHideFullscreen && layer.fullscreenUp
readonly property var workspaceMonitor: Hyprland.monitorFor(layer.screen)
readonly property var activeWorkspace: workspaceMonitor ? workspaceMonitor.activeWorkspace : null
readonly property bool fullscreenUp: {
const workspace = layer.activeWorkspace;
if (!workspace)
return false;
return Hyprland.toplevels.values.some((t) => {
const o = t.lastIpcObject || {};
return o.workspace && o.workspace.id === workspace.id &&
(o.fullscreen || 0) > 0 && o.mapped !== false;
});
}
screen: unit.modelData
visible: Prefs.loaded && Widgets.loaded && (deck.panelLive || ((Prefs.widgetsEnabled || deck.stowing) && Widgets.count > 0 && !layer.suppressed))
color: "transparent"
// reserves nothing and refuses to be shrunk into the bar and dock's strips,
// so a card can be dragged to a true screen edge and sit under the dock.
// setting exclusiveZone at all would silently undo this
exclusionMode: ExclusionMode.Ignore
// the widget panel lifts the whole board over the windows until it has slid away
WlrLayershell.layer: deck.panelLive ? WlrLayer.Overlay : (Prefs.widgetOnTop ? WlrLayer.Top : WlrLayer.Bottom)
WlrLayershell.keyboardFocus: deck.panelUp ? WlrKeyboardFocus.Exclusive : (deck.wantsKeyboard ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None)
anchors {
top: true
bottom: true
left: true
right: true
}
// lastIpcObject only moves when something asks it to, so nudge it on the
// events that can change whether a window is fullscreen
Connections {
function onRawEvent(event) {
if (event.name === "fullscreen" || event.name === "activewindowv2" || event.name === "closewindow" || event.name === "workspace" || event.name === "focusedmon")
fullscreenPoll.restart();
}
enabled: Prefs.widgetHideFullscreen
target: Hyprland
}
Timer {
id: stowEnd
onTriggered: deck.stowing = false
}
Timer {
id: fullscreenPoll
interval: 120
onTriggered: Hyprland.refreshToplevels()
}
Item {
id: deck
// bumped whenever the repeater's children change, so frameAt() bindings re-run
property int rev: 0
property Item dragFrame: null
readonly property string screenName: layer.screen ? layer.screen.name : ""
readonly property bool isPrimary: layer.screen !== null && layer.screen === Monitors.mainScreen
// the widget panel is open on this screen, and still on its way out
readonly property bool panelUp: Widgets.panelScreen !== "" && Widgets.panelScreen === deck.screenName
readonly property bool panelLive: deck.panelUp || panel.shown
// the cards are out: on, or the panel is up to place them
readonly property bool widgetsOn: Prefs.widgetsEnabled || deck.panelLive
// put away a moment ago: the board stays up while the cards go
property bool stowing: false
readonly property int stowStep: Math.round(30 * Theme.motionScale)
readonly property int wakeStep: Math.round(55 * Theme.motionScale)
// the order the cards go and come back in: down the screen, then across
function orderOf(f) {
var n = 0;
for (var i = 0; i < rep.count; i++) {
var o = rep.itemAt(i);
if (o && o !== f && o.onThisScreen && (o.y < f.y - 1 || (Math.abs(o.y - f.y) <= 1 && o.x < f.x)))
n++;
}
return n;
}
onWidgetsOnChanged: {
if (deck.widgetsOn) {
stowEnd.stop();
deck.stowing = false;
} else {
stowEnd.interval = rep.count * deck.stowStep + Theme.durMedium + 80;
deck.stowing = true;
stowEnd.restart();
}
}
readonly property int frameCount: rep.count
readonly property bool editing: Widgets.editUid !== "" && deck.editFrame !== null
// only ask for keys at all once something you can type into is placed;
// on-demand hands focus back the moment a window is clicked, which
// exclusive would not
readonly property bool wantsKeyboard: {
for (var i = 0; i < Widgets.model.count; i++) {
var e = Widgets.model.get(i);
if (!e.closing && (e.wtype === "notes" || e.wtype === "todo" || e.wtype === "github"))
return true;
}
return false;
}
readonly property bool grabbing: Widgets.dragUid !== "" || Widgets.menuUid !== "" || deck.editing
readonly property Item menuFrame: {
var _ = deck.rev;
for (var i = 0; i < rep.count; i++) {
var f = rep.itemAt(i);
if (f && f.uid === Widgets.menuUid && f.visible)
return f;
}
return null;
}
readonly property Item editFrame: {
var _ = deck.rev;
for (var i = 0; i < rep.count; i++) {
var f = rep.itemAt(i);
if (f && f.uid === Widgets.editUid && f.visible)
return f;
}
return null;
}
function frameAt(i) {
var _ = deck.rev;
return (i >= 0 && i < rep.count) ? rep.itemAt(i) : null;
}
function frameFor(uid) {
for (var i = 0; i < rep.count; i++) {
var f = rep.itemAt(i);
if (f && f.uid === uid)
return f;
}
return null;
}
// a board point over the open panel, where a dragged card is let go of
function binHit(px, py) {
return deck.panelUp && panel.sheetHit(px, py);
}
// the topmost card under a board point, for the menu overlay: it
// covers the screen while it is up, so the cards never see the press
function frameUnder(px, py) {
var best = null;
for (var i = 0; i < rep.count; i++) {
var f = rep.itemAt(i);
if (!f || !f.visible || f.closing)
continue;
if (px < f.x || px > f.x + f.width || py < f.y || py > f.y + f.height)
continue;
if (best === null || f.z >= best.z)
best = f;
}
return best;
}
anchors.fill: parent
focus: true
Keys.onEscapePressed: {
var busy = Widgets.editUid !== "" || Widgets.menuUid !== "" || Object.keys(Widgets.selection).length > 0;
Widgets.editUid = "";
Widgets.menuUid = "";
Widgets.clearSelection();
if (!busy && deck.panelUp)
Widgets.closePanel();
}
onWidthChanged: deck.report()
onHeightChanged: deck.report()
Component.onCompleted: deck.report()
function report() {
if (deck.isPrimary && deck.width > 0) {
Widgets.canvasW = deck.width;
Widgets.canvasH = deck.height;
}
}
// the dim behind the cards while the widget panel is open, and the way out of it
Rectangle {
anchors.fill: parent
color: Theme.alpha(Theme.cScrim, Theme.blurAmount > 0 ? 0.3 : 0.5)
opacity: panel.reveal
visible: opacity > 0.01
WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
enabled: deck.panelUp
onWheel: (event) => {
return event.accepted = true;
}
}
}
// click-through everywhere except the cards, so this only fires for the
// open menu, a note being typed in, or the widget panel
MouseArea {
anchors.fill: parent
enabled: deck.grabbing || deck.panelUp
acceptedButtons: Qt.LeftButton | Qt.RightButton
onPressed: {
var busy = Widgets.menuUid !== "" || Widgets.editUid !== "";
Widgets.menuUid = "";
Widgets.editUid = "";
if (!busy && deck.panelUp) {
Widgets.clearSelection();
Widgets.closePanel();
}
}
}
// the cards stack among themselves, under the guides and the panel
Item {
anchors.fill: parent
Repeater {
id: rep
model: Widgets.model
onItemAdded: deck.rev++
onItemRemoved: deck.rev++
WidgetFrame {
board: deck
}
}
}
Rectangle {
width: 1
height: deck.height
x: deck.dragFrame ? deck.dragFrame.guideX : -1
color: Theme.accent
opacity: (deck.dragFrame && deck.dragFrame.guideX >= 0) ? 0.65 : 0
visible: opacity > 0.01
z: 900
Behavior on opacity {
NumberAnimation {
duration: Theme.durQuick
}
}
}
Rectangle {
height: 1
width: deck.width
y: deck.dragFrame ? deck.dragFrame.guideY : -1
color: Theme.accent
opacity: (deck.dragFrame && deck.dragFrame.guideY >= 0) ? 0.65 : 0
visible: opacity > 0.01
z: 900
Behavior on opacity {
NumberAnimation {
duration: Theme.durQuick
}
}
}
WidgetPanel {
id: panel
anchors.fill: parent
z: 1000
board: deck
}
}
mask: Region {
x: 0
y: 0
width: (deck.grabbing || deck.panelUp) ? layer.width : 0
height: (deck.grabbing || deck.panelUp) ? layer.height : 0
WidgetRegion {
frame: deck.frameAt(0)
}
WidgetRegion {
frame: deck.frameAt(1)
}
WidgetRegion {
frame: deck.frameAt(2)
}
WidgetRegion {
frame: deck.frameAt(3)
}
WidgetRegion {
frame: deck.frameAt(4)
}
WidgetRegion {
frame: deck.frameAt(5)
}
WidgetRegion {
frame: deck.frameAt(6)
}
WidgetRegion {
frame: deck.frameAt(7)
}
WidgetRegion {
frame: deck.frameAt(8)
}
WidgetRegion {
frame: deck.frameAt(9)
}
WidgetRegion {
frame: deck.frameAt(10)
}
WidgetRegion {
frame: deck.frameAt(11)
}
WidgetRegion {
frame: deck.frameAt(12)
}
WidgetRegion {
frame: deck.frameAt(13)
}
WidgetRegion {
frame: deck.frameAt(14)
}
WidgetRegion {
frame: deck.frameAt(15)
}
WidgetRegion {
frame: deck.frameAt(16)
}
WidgetRegion {
frame: deck.frameAt(17)
}
WidgetRegion {
frame: deck.frameAt(18)
}
WidgetRegion {
frame: deck.frameAt(19)
}
}
// the open panel frosts the whole screen behind its dim, cards and sheet alike
BackgroundEffect.blurRegion: Theme.blurAmount > 0 ? (deck.panelUp ? wholeBlur : widgetBlur) : null
Region {
id: wholeBlur
width: layer.width
height: layer.height
}
Region {
id: widgetBlur
WidgetRegion {
blur: true
frame: deck.frameAt(0)
}
WidgetRegion {
blur: true
frame: deck.frameAt(1)
}
WidgetRegion {
blur: true
frame: deck.frameAt(2)
}
WidgetRegion {
blur: true
frame: deck.frameAt(3)
}
WidgetRegion {
blur: true
frame: deck.frameAt(4)
}
WidgetRegion {
blur: true
frame: deck.frameAt(5)
}
WidgetRegion {
blur: true
frame: deck.frameAt(6)
}
WidgetRegion {
blur: true
frame: deck.frameAt(7)
}
WidgetRegion {
blur: true
frame: deck.frameAt(8)
}
WidgetRegion {
blur: true
frame: deck.frameAt(9)
}
WidgetRegion {
blur: true
frame: deck.frameAt(10)
}
WidgetRegion {
blur: true
frame: deck.frameAt(11)
}
WidgetRegion {
blur: true
frame: deck.frameAt(12)
}
WidgetRegion {
blur: true
frame: deck.frameAt(13)
}
WidgetRegion {
blur: true
frame: deck.frameAt(14)
}
WidgetRegion {
blur: true
frame: deck.frameAt(15)
}
WidgetRegion {
blur: true
frame: deck.frameAt(16)
}
WidgetRegion {
blur: true
frame: deck.frameAt(17)
}
WidgetRegion {
blur: true
frame: deck.frameAt(18)
}
WidgetRegion {
blur: true
frame: deck.frameAt(19)
}
}
}
// an options panel on the widget layer would sit under every window, so it
// gets an overlay of its own that only exists while the panel is up
PanelWindow {
id: menuLayer
// the frame the panel belongs to, held past the close so the fade-out
// keeps its geometry and its contents
property Item held: null
readonly property Item target: deck.menuFrame
screen: unit.modelData
// up the moment the menu opens, so the fade-in plays inside a mapped
// surface, and stays up until the fade-out finishes
visible: menuLayer.target !== null || menu.visible
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: menuLayer.target !== null ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
onTargetChanged: if (menuLayer.target)
menuLayer.held = menuLayer.target
onVisibleChanged: if (!menuLayer.visible)
menuLayer.held = null
anchors {
top: true
bottom: true
left: true
right: true
}
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: Widgets.menuUid = ""
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onPressed: (mouse) => {
var f = mouse.button === Qt.RightButton ? deck.frameUnder(mouse.x, mouse.y) : null;
var was = Widgets.menuUid;
Widgets.menuUid = "";
// a right press on another card carries the panel over to it,
// the way it did when the panel still sat on the widget layer
if (f && f.uid !== was)
f.toggleMenu(mouse.x - f.x, mouse.y - f.y);
}
}
WidgetMenu {
id: menu
frame: menuLayer.held
fieldW: menuLayer.width
fieldH: menuLayer.height
}
}
// no input once the panel starts closing, so the fade-out is never a
// dead region over the screen
mask: Region {
width: menuLayer.target ? menuLayer.width : 0
height: menuLayer.target ? menuLayer.height : 0
}
BackgroundEffect.blurRegion: Theme.blurAmount > 0 ? menuBlur : null
Region {
id: menuBlur
WidgetRegion {
blur: true
part: "menu"
panel: menu
}
}
}
}
}
+1 -1
View File
@@ -153,7 +153,7 @@ in {
bind = [
(bind "SUPER + V" (exec "ghostty --title=clipse -e clipse"))
(bind "SUPER + SHIFT + V" (exec "qs ipc call launcher clipboard"))
(hyprLua.bind "SUPER + SHIFT + V" (hyprLua.execLauncher "qs ipc call launcher clipboard"))
(bind "Print" (exec "qs ipc call snap toggle"))
(bind "SUPER + Print" (exec "qs ipc call screenshot full"))
(bind "CTRL + Print" (exec "qs ipc call snap region"))
+6
View File
@@ -7,6 +7,12 @@ in rec {
exec = command: "hl.dsp.exec_cmd(${quote command})";
# Hyprland 0.57 can leave exclusive layer-shell launchers unfocused when
# follow_mouse = 1 and the pointer is already over a regular window.
execLauncher = command: ''
hl.dsp.exec_cmd(${quote ("hyprctl eval 'hl.config({ input = { follow_mouse = 2 } })' && " + command)})
'';
bind = keys: dispatcher: {
_args = [
keys
+3
View File
@@ -107,6 +107,9 @@ ShellRoot {
id: toastMod
}
Osd {
}
ToastEvents {
toast: toastMod
}
+350
View File
@@ -0,0 +1,350 @@
{
"nextId": 31,
"boardFull": true,
"laidScale": 1.1,
"preset": "",
"saved": [
{
"id": "user-mvzmbsn0",
"name": "Mine",
"refW": 3440,
"refH": 1440,
"refScale": 1.1,
"saved": 1793656367208,
"cards": [
{
"uid": "w1",
"type": "clock",
"variant": "poster",
"at": "tr",
"x": 3122,
"y": 93,
"w": 216,
"h": 272,
"zoom": 1.25,
"stretch": false,
"pinned": false,
"opts": {
"hourMode": "auto",
"seconds": false,
"dot": true,
"showDate": true,
"shape": "cookie9",
"zones": "eu",
"tone": "auto"
}
},
{
"uid": "w2",
"type": "system",
"variant": "tiles",
"at": "bl",
"x": 20,
"y": 1057,
"w": 280,
"h": 220,
"zoom": 1.5,
"stretch": false,
"pinned": false,
"opts": {
"showCpu": true,
"showRam": true,
"showDisk": true,
"showTemp": true,
"showGpu": false,
"interval": "5",
"tone": "auto"
}
},
{
"uid": "w3",
"type": "progress",
"variant": "dots",
"at": "tr",
"x": 3007,
"y": 483,
"w": 300,
"h": 212,
"zoom": 1.25,
"stretch": false,
"pinned": false,
"opts": {
"span": "day",
"tone": "auto"
}
},
{
"uid": "w4",
"type": "network",
"variant": "graph",
"at": "bl",
"x": 20,
"y": 836,
"w": 308,
"h": 186,
"zoom": 1,
"stretch": false,
"pinned": false,
"opts": {
"units": "bytes",
"showAddress": true,
"interval": "1",
"tone": "auto"
}
},
{
"uid": "w5",
"type": "media",
"variant": "card",
"at": "tl",
"x": 20,
"y": 93,
"w": 288,
"h": 435,
"zoom": 1,
"stretch": false,
"pinned": false,
"opts": {
"artTint": true,
"showProgress": true,
"scroll": true,
"tone": "auto"
}
},
{
"uid": "w7",
"type": "games",
"variant": "list",
"at": "br",
"x": 2925,
"y": 958,
"w": 300,
"h": 280,
"zoom": 1.5,
"stretch": false,
"pinned": false,
"opts": {
"order": "recent",
"gameModeOnLaunch": false,
"tone": "auto"
}
},
{
"uid": "w8",
"type": "kdeconnect",
"variant": "remote",
"at": "tr",
"x": 2713,
"y": 93,
"w": 300,
"h": 214,
"zoom": 1,
"stretch": false,
"pinned": false,
"opts": {
"showShare": true,
"showRing": true,
"showClipboard": true,
"showBrowse": true,
"deviceId": "",
"tone": "auto"
}
},
{
"uid": "w9",
"type": "fetch",
"variant": "card",
"at": "tl",
"x": 375,
"y": 105,
"w": 330,
"h": 344,
"zoom": 1,
"stretch": false,
"pinned": false,
"opts": {
"tone": "auto"
}
}
]
}
],
"last": [],
"stash": [
{
"uid": "w19",
"type": "notes",
"variant": "sticky",
"wx": 53,
"wy": 515,
"bw": 244,
"bh": 244,
"pinned": false,
"zoom": 1.33,
"zOrder": 66,
"screenName": "",
"opts": {
"tint": "tertiary",
"edited": 0,
"text": "",
"tone": "auto"
}
}
],
"instances": [
{
"uid": "w24",
"type": "clock",
"variant": "poster",
"wx": 3122,
"wy": 93,
"bw": 216,
"bh": 272,
"pinned": false,
"zoom": 1.25,
"zOrder": 80,
"screenName": "",
"opts": {
"hourMode": "auto",
"seconds": false,
"dot": true,
"showDate": true,
"shape": "cookie9",
"zones": "eu",
"tone": "auto"
}
},
{
"uid": "w26",
"type": "progress",
"variant": "dots",
"wx": 3007,
"wy": 483,
"bw": 300,
"bh": 212,
"pinned": false,
"zoom": 1.25,
"zOrder": 82,
"screenName": "",
"opts": {
"span": "day",
"tone": "auto"
}
},
{
"uid": "w2",
"type": "system",
"variant": "tiles",
"wx": 20,
"wy": 1057,
"bw": 280,
"bh": 220,
"pinned": false,
"zoom": 1.5,
"zOrder": 81,
"screenName": "",
"opts": {
"showCpu": true,
"showRam": true,
"showDisk": true,
"showTemp": true,
"showGpu": false,
"interval": "5",
"tone": "auto"
}
},
{
"uid": "w4",
"type": "network",
"variant": "graph",
"wx": 20,
"wy": 836,
"bw": 308,
"bh": 186,
"pinned": false,
"zoom": 1,
"zOrder": 88,
"screenName": "",
"opts": {
"units": "bytes",
"showAddress": true,
"interval": "1",
"tone": "auto"
}
},
{
"uid": "w5",
"type": "media",
"variant": "card",
"wx": 20,
"wy": 93,
"bw": 288,
"bh": 435,
"pinned": false,
"zoom": 1,
"zOrder": 89,
"screenName": "",
"opts": {
"artTint": true,
"showProgress": true,
"scroll": true,
"tone": "auto"
}
},
{
"uid": "w7",
"type": "games",
"variant": "list",
"wx": 2925,
"wy": 958,
"bw": 300,
"bh": 280,
"pinned": false,
"zoom": 1.5,
"zOrder": 85,
"screenName": "",
"opts": {
"order": "recent",
"gameModeOnLaunch": false,
"tone": "auto"
}
},
{
"uid": "w8",
"type": "kdeconnect",
"variant": "remote",
"wx": 2713,
"wy": 93,
"bw": 300,
"bh": 214,
"pinned": false,
"zoom": 1,
"zOrder": 86,
"screenName": "",
"opts": {
"showShare": true,
"showRing": true,
"showClipboard": true,
"showBrowse": true,
"deviceId": "",
"tone": "auto"
}
},
{
"uid": "w9",
"type": "fetch",
"variant": "terminal",
"wx": 353,
"wy": 93,
"bw": 380,
"bh": 318,
"pinned": false,
"zoom": 1,
"zOrder": 90,
"screenName": "",
"opts": {
"tone": "auto"
}
}
]
}
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Read-only bridge from Thunderbird's synchronized CalDAV event cache."""
import configparser
import datetime as dt
import json
import re
import shutil
import sqlite3
import tempfile
import time
from pathlib import Path
HOME = Path.home()
DB_NAME = "cache.sqlite"
WINDOW_START = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=30)
WINDOW_END = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=400)
def active_profile():
ini = configparser.ConfigParser(interpolation=None)
ini.read(HOME / ".thunderbird" / "profiles.ini", encoding="utf-8")
for section in ini.sections():
if not section.startswith("Profile") or ini.get(section, "Default", fallback="0") != "1":
continue
profile = Path(ini.get(section, "Path"))
if ini.get(section, "IsRelative", fallback="1") == "1":
profile = HOME / ".thunderbird" / profile
return profile
raise FileNotFoundError("No default Thunderbird profile")
def calendar_registry(profile):
prefs = profile / "prefs.js"
pattern = re.compile(r'^user_pref\("calendar\.registry\.([^.]+)\.([^\"]+)",\s*(.*)\);$')
registry = {}
if not prefs.exists():
return registry
with prefs.open(encoding="utf-8", errors="replace") as source:
for line in source:
match = pattern.match(line.strip())
if not match:
continue
calendar_id, key, raw = match.groups()
try:
value = json.loads(raw)
except json.JSONDecodeError:
continue
registry.setdefault(calendar_id, {})[key] = value
return {
calendar_id: data
for calendar_id, data in registry.items()
if data.get("type") == "caldav" and not data.get("disabled", False)
}
def stable_snapshot(database, target_dir):
"""Copy DB and WAL together; retry if Thunderbird writes during the copy."""
target = target_dir / DB_NAME
sidecars = [Path(str(database) + suffix) for suffix in ("", "-wal", "-shm")]
for _ in range(4):
before = []
for path in sidecars:
try:
stat = path.stat()
before.append((stat.st_size, stat.st_mtime_ns))
except FileNotFoundError:
before.append(None)
for path, state in zip(sidecars, before):
dest = Path(str(target) + str(path)[len(str(database)):])
if state is None:
dest.unlink(missing_ok=True)
else:
shutil.copy2(path, dest)
after = []
for path in sidecars:
try:
stat = path.stat()
after.append((stat.st_size, stat.st_mtime_ns))
except FileNotFoundError:
after.append(None)
if before == after:
return target
time.sleep(0.05)
raise RuntimeError("Thunderbird calendar cache changed during snapshot")
def parse_until(value):
if not value:
return None
value = value.strip()
try:
if re.fullmatch(r"\d{8}", value):
return dt.datetime.strptime(value, "%Y%m%d").replace(tzinfo=dt.timezone.utc)
return dt.datetime.strptime(value.rstrip("Z"), "%Y%m%dT%H%M%S").replace(tzinfo=dt.timezone.utc)
except ValueError:
return None
def parse_recurrence(text):
rules = []
excluded = set()
for line in text.replace("\r", "").split("\n"):
if line.startswith("RRULE:"):
rules.append(dict(part.split("=", 1) for part in line[6:].split(";") if "=" in part))
elif line.startswith("EXDATE") and ":" in line:
values = line.split(":", 1)[1].split(",")
for value in values:
parsed = parse_until(value)
if parsed:
excluded.add(int(parsed.timestamp() * 1000))
return rules, excluded
def month_dates(year, month, base_day, rule):
import calendar
month_days = int(calendar.monthrange(year, month)[1])
if "BYMONTHDAY" in rule:
days = []
for raw in rule["BYMONTHDAY"].split(","):
value = int(raw)
day = value if value > 0 else month_days + value + 1
if 1 <= day <= month_days:
days.append(day)
return sorted(set(days))
if "BYDAY" not in rule:
return [base_day] if base_day <= month_days else []
weekdays = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
result = []
for entry in rule["BYDAY"].split(","):
match = re.fullmatch(r"([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)", entry)
if not match:
continue
ordinal, name = match.groups()
weekday = weekdays[name]
matching = [day for day in range(1, month_days + 1) if dt.date(year, month, day).weekday() == weekday]
if ordinal:
index = int(ordinal)
if index < 0:
index = len(matching) + index + 1
if 1 <= index <= len(matching):
result.append(matching[index - 1])
else:
result.extend(matching)
return sorted(set(result))
def occurrences(start, rule, excluded):
freq = rule.get("FREQ", "").upper()
interval = max(1, int(rule.get("INTERVAL", "1")))
count_limit = int(rule["COUNT"]) if rule.get("COUNT", "").isdigit() else None
until = parse_until(rule.get("UNTIL"))
candidates = []
base_date = start.date()
if freq == "DAILY":
day = base_date
while day <= WINDOW_END.date():
delta = (day - base_date).days
if delta % interval == 0:
candidates.append(dt.datetime.combine(day, start.timetz()))
day += dt.timedelta(days=1)
elif freq == "WEEKLY":
days = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
weekdays = sorted({days[x] for x in rule.get("BYDAY", "").split(",") if x in days}) or [base_date.weekday()]
week = base_date - dt.timedelta(days=base_date.weekday())
while week <= WINDOW_END.date():
weeks = (week - (base_date - dt.timedelta(days=base_date.weekday()))).days // 7
if weeks % interval == 0:
for weekday in weekdays:
day = week + dt.timedelta(days=weekday)
if day >= base_date:
candidates.append(dt.datetime.combine(day, start.timetz()))
week += dt.timedelta(days=7)
elif freq == "MONTHLY":
month_index = base_date.year * 12 + base_date.month - 1
last_index = WINDOW_END.year * 12 + WINDOW_END.month - 1
for index in range(month_index, last_index + 1, interval):
year, month0 = divmod(index, 12)
for day in month_dates(year, month0 + 1, base_date.day, rule):
date = dt.date(year, month0 + 1, day)
if date >= base_date:
candidates.append(dt.datetime.combine(date, start.timetz()))
elif freq == "YEARLY":
for year in range(base_date.year, WINDOW_END.year + 1, interval):
months = [int(x) for x in rule.get("BYMONTH", str(base_date.month)).split(",")]
for month in months:
for day in month_dates(year, month, base_date.day, rule):
date = dt.date(year, month, day)
if date >= base_date:
candidates.append(dt.datetime.combine(date, start.timetz()))
candidates.sort()
result = []
for ordinal, occurrence in enumerate(candidates):
if count_limit is not None and ordinal >= count_limit:
break
if until and occurrence > until:
break
stamp = int(occurrence.timestamp() * 1000)
if WINDOW_START.timestamp() * 1000 <= stamp <= WINDOW_END.timestamp() * 1000 and stamp not in excluded:
result.append(stamp)
return result
def main():
profile = active_profile()
registry = calendar_registry(profile)
database = profile / "calendar-data" / DB_NAME
events = []
if registry and database.exists():
with tempfile.TemporaryDirectory(prefix="lucid-tb-calendar-") as td:
snapshot = stable_snapshot(database, Path(td))
db = sqlite3.connect(snapshot)
db.row_factory = sqlite3.Row
recurrence = {}
for row in db.execute("SELECT cal_id, item_id, icalString FROM cal_recurrence"):
recurrence[(row["cal_id"], row["item_id"])] = parse_recurrence(row["icalString"] or "")
rows = list(db.execute(
"SELECT cal_id, id, title, event_start, event_end, recurrence_id, ical_status "
"FROM cal_events WHERE event_start IS NOT NULL"
))
overrides = set()
for row in rows:
if row["recurrence_id"] is not None:
try:
overrides.add((row["cal_id"], row["id"], int(row["recurrence_id"])))
except (TypeError, ValueError):
pass
for row in rows:
cal_id = row["cal_id"]
if cal_id not in registry or (row["ical_status"] or "").upper() == "CANCELLED":
continue
try:
start_us = int(row["event_start"])
except (TypeError, ValueError):
continue
start_ms = start_us // 1000
duration_ms = max(0, int(row["event_end"] or row["event_start"]) // 1000 - start_ms)
title = (row["title"] or "(untitled event)").strip()
cal_name = registry[cal_id].get("name") or "CalDAV"
rule_data = recurrence.get((cal_id, row["id"]))
starts = [start_ms]
if rule_data:
rules, excluded = rule_data
if rules:
base = dt.datetime.fromtimestamp(start_ms / 1000, dt.timezone.utc)
starts = occurrences(base, rules[0], excluded)
for occurrence_ms in starts:
if (cal_id, row["id"], occurrence_ms * 1000) in overrides:
continue
if not (WINDOW_START.timestamp() * 1000 <= occurrence_ms <= WINDOW_END.timestamp() * 1000):
continue
events.append({
"id": f"{cal_id}:{row['id']}:{occurrence_ms}",
"name": title,
"calendar": cal_name,
"startMs": occurrence_ms,
"endMs": occurrence_ms + duration_ms,
})
db.close()
events.sort(key=lambda event: (event["startMs"], event["name"].casefold()))
print(json.dumps({"events": events}, ensure_ascii=False, separators=(",", ":")))
if __name__ == "__main__":
try:
main()
except Exception:
# Keep refresh failures out of Lucid's console: current calendar events
# remain visible until the next successful sync.
print(json.dumps({"events": [], "error": "Thunderbird calendar cache unavailable"}))
+4 -1
View File
@@ -189,6 +189,9 @@ in {
};
wayland.windowManager.hyprland.settings.bind = [
(hyprLua.bind "SUPER + D" (hyprLua.exec "walker"))
(hyprLua.bind "SUPER + D"
(if config.home.username == "sweetbread"
then hyprLua.execLauncher "qs ipc call launcher toggle"
else hyprLua.exec "walker"))
];
}
+1
View File
@@ -1,6 +1,7 @@
{ pkgs, pkgs-pinned, host, ... }: {
imports = [
../../modules/user/packages/cs_utils.nix
../../modules/user/wob.nix
];
disabledModules = [
../../modules/user/packages/art.nix
+4 -1
View File
@@ -1,4 +1,7 @@
{ pkgs, ... }: {
{ pkgs, lib, osConfig, ... }: {
imports = [ ./modules/lucid.nix ]
++ lib.optional (osConfig.networking.hostName != "Rias") ../../modules/user/wob.nix;
programs.hyprlock.enable = true;
home.packages = with pkgs; [
android-studio
+1 -1
View File
@@ -199,7 +199,7 @@
];
bind = [
(hyprLua.bind "SUPER + W" (hyprLua.exec "qs ipc call launcher wallpaper"))
(hyprLua.bind "SUPER + W" (hyprLua.execLauncher "qs ipc call launcher wallpaper"))
(hyprLua.bind "SUPER + SHIFT + W" (hyprLua.exec "${lib.getExe wallpaper_changer}"))
];
};
+38 -2
View File
@@ -19,8 +19,11 @@ let
sparseCheckout = [ "/wallpapers/catppuccin-mocha/*" ];
nonConeMode = true;
};
wallpaperDir = "${config.home.homeDirectory}/Pictures/wallpapers/stylix";
wallpaperDir = "${config.home.homeDirectory}/Wallpapers";
wallpaperScript = "${config.home.homeDirectory}/.config/hypr/scripts/wallpaper/set-wallpaper.sh";
widgetLayout = if osConfig.networking.hostName == "Rias"
then ../../../modules/user/lucid-widgets-rias.json
else ../../../modules/user/lucid-widgets.json;
defaultWallpaper = "${wallpaperDir}/bluehour.jpg";
hyprLua = import ../../../modules/user/hyprland/lua.nix { inherit lib; };
colors = config.lib.stylix.colors.withHashtag;
@@ -137,8 +140,41 @@ in {
--exclude='/lucidkeys/state.json' \
--exclude='/lucidwidgets/widgets.json' \
"${source}/" "$target/"
dock_qml="$target/luciddocks/Dock.qml"
if grep -Fq '/var/lib/snapd/desktop/applications; do' "$dock_qml"; then
$DRY_RUN_CMD sed -i \
's#/var/lib/snapd/desktop/applications; do#/var/lib/snapd/desktop/applications $HOME/.nix-profile/share/applications $HOME/.local/state/nix/profile/share/applications /etc/profiles/per-user/$USER/share/applications /nix/profile/share/applications /nix/var/nix/profiles/default/share/applications /run/current-system/sw/share/applications; do#' \
"$dock_qml"
elif ! grep -Fq '/etc/profiles/per-user/$USER/share/applications; do' "$dock_qml"; then
echo "Lucid launcher application scan pattern changed; refusing to skip Nix profiles" >&2
exit 1
fi
keyboard_focus='WlrLayershell.keyboardFocus: dockWindow.menuOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None'
keyboard_focus_fixed='WlrLayershell.keyboardFocus: dockWindow.menuOpen ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None'
if grep -Fq "$keyboard_focus" "$dock_qml"; then
$DRY_RUN_CMD sed -i \
's/WlrLayershell.keyboardFocus: dockWindow.menuOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None/WlrLayershell.keyboardFocus: dockWindow.menuOpen ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None/' \
"$dock_qml"
elif ! grep -Fq "$keyboard_focus_fixed" "$dock_qml"; then
echo "Lucid launcher keyboard focus pattern changed; refusing to skip focus fix" >&2
exit 1
fi
if ! grep -Fq 'Nix focus workaround for Hyprland 0.57' "$dock_qml"; then
if [ "$(grep -Fxc ' onMenuOpenChanged: {' "$dock_qml")" -ne 1 ]; then
echo "Lucid menu-open handler changed; refusing to skip focus workaround" >&2
exit 1
fi
$DRY_RUN_CMD sed -i '/ onMenuOpenChanged: {/a\
// Nix focus workaround for Hyprland 0.57: detach pointer and keyboard focus while menus are open.\
Quickshell.execDetached(["hyprctl", "eval", "hl.config({ input = { follow_mouse = " + (dockWindow.menuOpen ? 2 : 1) + " } })"]);' "$dock_qml"
fi
$DRY_RUN_CMD install -m644 ${../../../modules/user/lucid-shell.qml} "$target/shell.qml"
$DRY_RUN_CMD install -m644 ${../../../modules/user/LucidBar.qml} "$target/LucidBar.qml"
$DRY_RUN_CMD install -m644 ${../../../modules/user/Agenda.qml} "$target/Agenda.qml"
$DRY_RUN_CMD install -m644 ${../../../modules/user/CalendarSource.qml} "$target/CalendarSource.qml"
$DRY_RUN_CMD install -m644 ${../../../modules/user/ClockCalendar.qml} "$target/lucidbar/ClockCalendar.qml"
$DRY_RUN_CMD install -m644 ${../../../modules/user/thunderbird-calendar.py} "$target/thunderbird-calendar.py"
$DRY_RUN_CMD install -m644 ${../../../modules/user/WidgetLayer.qml} "$target/lucidwidgets/WidgetLayer.qml"
$DRY_RUN_CMD mkdir -p "${wallpaperDir}" \
"${config.home.homeDirectory}/.config/hypr/scripts/wallpaper"
$DRY_RUN_CMD ${pkgs.rsync}/bin/rsync -rltp --chmod=Du+rwx,Fu+rw --ignore-existing \
@@ -179,7 +215,7 @@ in {
$DRY_RUN_CMD install -m644 ${../../../modules/user/lucid-pinned.json} "$target/luciddocks/pinned.json"
fi
if [ ! -e "$target/lucidwidgets/widgets.json" ]; then
$DRY_RUN_CMD install -m644 ${../../../modules/user/lucid-widgets.json} "$target/lucidwidgets/widgets.json"
$DRY_RUN_CMD install -m644 ${widgetLayout} "$target/lucidwidgets/widgets.json"
fi
for pair in \