From 90c1704f870aaafac7fb181e954b29740b39f7d1 Mon Sep 17 00:00:00 2001 From: Raul Benencia Date: Mon, 13 Apr 2026 08:11:52 -0700 Subject: org-agenda-shell Initial version of org-agenda-shell. Alpha. --- .emacs.d/rul-lisp/packages/org-agenda-shell.el | 214 ++++++++ .emacs.d/rul-lisp/packages/rul-org.el | 2 + .../extension.js | 548 +++++++++++++++++++++ .../metadata.json | 11 + .../org-agenda-indicator@rbenencia.name/prefs.js | 33 ++ ...ell.extensions.org-agenda-indicator.gschema.xml | 14 + .../stylesheet.css | 38 ++ bin/gnome-set-config | 38 +- 8 files changed, 891 insertions(+), 7 deletions(-) create mode 100644 .emacs.d/rul-lisp/packages/org-agenda-shell.el create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml create mode 100644 .local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css diff --git a/.emacs.d/rul-lisp/packages/org-agenda-shell.el b/.emacs.d/rul-lisp/packages/org-agenda-shell.el new file mode 100644 index 0000000..4c4c493 --- /dev/null +++ b/.emacs.d/rul-lisp/packages/org-agenda-shell.el @@ -0,0 +1,214 @@ +;;; org-agenda-shell.el --- Export Org agenda state for shell integrations -*- lexical-binding: t; -*- + +(require 'cl-lib) +(require 'json) +(require 'org) +(require 'org-clock) +(require 'seq) + +(defgroup org-agenda-shell nil + "Export Org agenda data for desktop integrations." + :group 'org) + +(defcustom org-agenda-shell-snapshot-path "~/.cache/org-agenda-shell/today.json" + "Path to the JSON snapshot consumed by external shell integrations." + :type 'file) + +(defcustom org-agenda-shell-export-idle-delay 2 + "Idle delay, in seconds, before exporting the agenda snapshot after changes." + :type 'number) + +(defvar org-agenda-shell--export-idle-timer nil + "Pending idle timer for agenda snapshot exports.") + +(defun org-agenda-shell--time-epoch (time) + "Return TIME as an integer Unix epoch." + (truncate (float-time time))) + +(defun org-agenda-shell--json-bool (value) + "Return VALUE encoded as a JSON boolean." + (if value t :json-false)) + +(defun org-agenda-shell--today-days () + "Return today's date as an absolute day count." + (time-to-days (current-time))) + +(defun org-agenda-shell--open-todo-p () + "Return non-nil when the current heading is an open TODO item." + (let ((state (org-get-todo-state))) + (and state + (not (member state org-done-keywords))))) + +(defun org-agenda-shell--scheduled-clock-string (scheduled) + "Return the HH:MM component extracted from SCHEDULED, if present." + (when (and scheduled + (string-match "\\([0-9]\\{1,2\\}:[0-9]\\{2\\}\\)" scheduled)) + (match-string 1 scheduled))) + +(defun org-agenda-shell--task-record () + "Return the current heading as an export task alist, or nil." + (let* ((scheduled (org-entry-get nil "SCHEDULED")) + (scheduled-time (and scheduled (org-time-string-to-time scheduled))) + (scheduled-days (and scheduled-time (time-to-days scheduled-time))) + (today-days (org-agenda-shell--today-days))) + (when (and scheduled + scheduled-time + scheduled-days + (<= scheduled-days today-days) + (org-agenda-shell--open-todo-p)) + (let* ((file (buffer-file-name (buffer-base-buffer))) + (begin (point)) + (task-id (or (org-entry-get nil "ID") + (format "%s::%d" file begin))) + (scheduled-for (format-time-string "%F" scheduled-time)) + (clock-time (org-agenda-shell--scheduled-clock-string scheduled)) + (title (org-get-heading t t t t)) + (state (org-get-todo-state))) + `((id . ,task-id) + (title . ,title) + (time . ,clock-time) + (state . ,state) + (category . ,(org-get-category)) + (scheduled_for . ,scheduled-for) + (is_today . ,(org-agenda-shell--json-bool + (= scheduled-days today-days))) + (is_overdue . ,(org-agenda-shell--json-bool + (< scheduled-days today-days))) + (source_file . ,file) + (_sort_days . ,scheduled-days) + (_sort_time . ,(or clock-time ""))))))) + +(defun org-agenda-shell--task< (left right) + "Return non-nil when LEFT should sort before RIGHT." + (let ((left-days (alist-get '_sort_days left)) + (right-days (alist-get '_sort_days right)) + (left-time (alist-get '_sort_time left)) + (right-time (alist-get '_sort_time right)) + (left-title (alist-get 'title left)) + (right-title (alist-get 'title right))) + (or (< left-days right-days) + (and (= left-days right-days) + (or (string< left-time right-time) + (and (string= left-time right-time) + (string< left-title right-title))))))) + +(defun org-agenda-shell--public-task (task) + "Return TASK without exporter-only sort keys." + (seq-remove + (lambda (pair) + (memq (car pair) '(_sort_days _sort_time))) + task)) + +(defun org-agenda-shell--collect-tasks () + "Return agenda tasks scheduled for today and overdue scheduled items." + (let (tasks) + (dolist (file (org-agenda-files)) + (when (file-readable-p file) + (let ((create-lockfiles nil)) + (with-current-buffer (find-file-noselect file) + (org-with-wide-buffer + (org-map-entries + (lambda () + (let ((task (org-agenda-shell--task-record))) + (when task + (push task tasks)))) + nil + 'file)))))) + (sort tasks #'org-agenda-shell--task<))) + +(defun org-agenda-shell--clocked-in-record () + "Return the currently clocked-in Org task as an alist, or nil." + (when (and (org-clocking-p) + (marker-buffer org-clock-marker)) + (org-with-point-at org-clock-marker + (let* ((file (buffer-file-name (buffer-base-buffer))) + (begin (point)) + (started-at org-clock-start-time) + (task-id (or (org-entry-get nil "ID") + (format "%s::%d" file begin)))) + `((id . ,task-id) + (title . ,(or org-clock-current-task + (org-get-heading t t t t))) + (state . ,(org-get-todo-state)) + (category . ,(org-get-category)) + (source_file . ,file) + (started_at . ,(format-time-string "%FT%T%z" started-at)) + (started_epoch . ,(org-agenda-shell--time-epoch started-at))))))) + +;;;###autoload +(defun org-agenda-shell-export () + "Write the JSON snapshot consumed by shell integrations." + (interactive) + (let* ((now (current-time)) + (json-encoding-pretty-print nil) + (tasks (mapcar #'org-agenda-shell--public-task + (org-agenda-shell--collect-tasks))) + (clocked-in (org-agenda-shell--clocked-in-record)) + (today-count (cl-count-if (lambda (task) + (eq t (alist-get 'is_today task))) + tasks)) + (overdue-count (cl-count-if (lambda (task) + (eq t (alist-get 'is_overdue task))) + tasks)) + (payload `((generated_at . ,(format-time-string "%FT%T%z" now)) + (generated_epoch . ,(org-agenda-shell--time-epoch now)) + (date . ,(format-time-string "%F" now)) + (task_count . ,(length tasks)) + (today_count . ,today-count) + (overdue_count . ,overdue-count) + (clocked_in . ,clocked-in) + (today_tasks . ,(vconcat tasks)))) + (target (expand-file-name org-agenda-shell-snapshot-path)) + (target-dir (file-name-directory target))) + (make-directory target-dir t) + (with-temp-file target + (insert (json-encode payload)) + (insert "\n")))) + +(defun org-agenda-shell-safe-export () + "Export the agenda snapshot and log any errors." + (setq org-agenda-shell--export-idle-timer nil) + (condition-case err + (org-agenda-shell-export) + (error + (message "org-agenda-shell export failed: %s" + (error-message-string err))))) + +(defun org-agenda-shell-schedule-export () + "Schedule an idle export of the agenda snapshot." + (when org-agenda-shell--export-idle-timer + (cancel-timer org-agenda-shell--export-idle-timer)) + (setq org-agenda-shell--export-idle-timer + (run-with-idle-timer + org-agenda-shell-export-idle-delay + nil + #'org-agenda-shell-safe-export))) + +(defun org-agenda-shell--after-save-hook () + "Refresh the agenda snapshot when an agenda file is saved." + (when (and buffer-file-name + (member (file-truename buffer-file-name) + (mapcar #'file-truename (org-agenda-files)))) + (org-agenda-shell-schedule-export))) + +;;;###autoload +(define-minor-mode org-agenda-shell-mode + "Keep a JSON snapshot of the Org agenda up to date." + :global t + (if org-agenda-shell-mode + (progn + (add-hook 'after-save-hook #'org-agenda-shell--after-save-hook) + (add-hook 'org-clock-in-hook #'org-agenda-shell-schedule-export) + (add-hook 'org-clock-out-hook #'org-agenda-shell-schedule-export) + (add-hook 'org-clock-cancel-hook #'org-agenda-shell-schedule-export) + (org-agenda-shell-schedule-export)) + (remove-hook 'after-save-hook #'org-agenda-shell--after-save-hook) + (remove-hook 'org-clock-in-hook #'org-agenda-shell-schedule-export) + (remove-hook 'org-clock-out-hook #'org-agenda-shell-schedule-export) + (remove-hook 'org-clock-cancel-hook #'org-agenda-shell-schedule-export) + (when org-agenda-shell--export-idle-timer + (cancel-timer org-agenda-shell--export-idle-timer) + (setq org-agenda-shell--export-idle-timer nil)))) + +(provide 'org-agenda-shell) +;;; org-agenda-shell.el ends here diff --git a/.emacs.d/rul-lisp/packages/rul-org.el b/.emacs.d/rul-lisp/packages/rul-org.el index 979fdab..f488ab0 100644 --- a/.emacs.d/rul-lisp/packages/rul-org.el +++ b/.emacs.d/rul-lisp/packages/rul-org.el @@ -5,6 +5,8 @@ (require 'org-habit) (require 'rul-org-agenda) +(require 'org-agenda-shell) +(org-agenda-shell-mode 1) (setq org-attach-use-inheritance t) (setq org-cycle-separator-lines 0) diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js new file mode 100644 index 0000000..b92048c --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js @@ -0,0 +1,548 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Clutter from 'gi://Clutter'; +import Pango from 'gi://Pango'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +const SNAPSHOT_RELATIVE_PATH = '.cache/org-agenda-shell/today.json'; +const REFRESH_INTERVAL_SECONDS = 60; +const RELOAD_DELAY_MS = 200; +const PANEL_TITLE_MAX = 20; + +const InfoMenuItem = GObject.registerClass( +class InfoMenuItem extends PopupMenu.PopupBaseMenuItem { + _init(primaryText, secondaryText = '', {overdue = false} = {}) { + super._init({reactive: false, can_focus: false}); + + const box = new St.BoxLayout({ + vertical: true, + x_expand: true, + style_class: 'org-agenda-indicator-row', + }); + + this._primaryLabel = new St.Label({ + text: primaryText, + x_expand: true, + style_class: 'org-agenda-indicator-row-title', + }); + this._primaryLabel.clutter_text.ellipsize = Pango.EllipsizeMode.END; + + if (overdue) + this._primaryLabel.add_style_class_name('org-agenda-indicator-overdue'); + + box.add_child(this._primaryLabel); + + if (secondaryText) { + this._secondaryLabel = new St.Label({ + text: secondaryText, + x_expand: true, + style_class: 'org-agenda-indicator-row-meta', + }); + this._secondaryLabel.clutter_text.ellipsize = Pango.EllipsizeMode.END; + box.add_child(this._secondaryLabel); + } + + this.add_child(box); + } +}); + +const SectionHeaderItem = GObject.registerClass( +class SectionHeaderItem extends PopupMenu.PopupBaseMenuItem { + _init(text) { + super._init({reactive: false, can_focus: false}); + + const label = new St.Label({ + text, + x_expand: true, + style_class: 'org-agenda-indicator-section-label', + }); + const box = new St.BoxLayout({ + x_expand: true, + style_class: 'org-agenda-indicator-section', + }); + + box.add_child(label); + this.add_child(box); + } +}); + +const Indicator = GObject.registerClass( +class Indicator extends PanelMenu.Button { + _init(settings) { + super._init(0.0, 'Org Agenda Indicator'); + + this._settings = settings; + this._snapshotPath = GLib.build_filenamev([ + GLib.get_home_dir(), + SNAPSHOT_RELATIVE_PATH, + ]); + this._snapshotDir = GLib.path_get_dirname(this._snapshotPath); + this._snapshotParentDir = GLib.path_get_dirname(this._snapshotDir); + this._snapshotDirBaseName = GLib.path_get_basename(this._snapshotDir); + this._snapshotBaseName = GLib.path_get_basename(this._snapshotPath); + this._directoryMonitor = null; + this._monitoredDirectory = null; + this._refreshSourceId = 0; + this._reloadSourceId = 0; + this._state = { + status: 'loading', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: null, + generatedAt: null, + generatedEpoch: null, + tasks: [], + clockedIn: null, + error: null, + }; + + this._label = new St.Label({ + text: '...', + y_align: Clutter.ActorAlign.CENTER, + name: 'org-agenda-indicator-label', + }); + this._label.clutter_text.ellipsize = Pango.EllipsizeMode.END; + this.add_child(this._label); + + this._showClockedInChangedId = this._settings.connect( + 'changed::show-clocked-in-task', + () => { + this._syncLabel(); + this._rebuildMenu(); + }); + + this._refreshSourceId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + REFRESH_INTERVAL_SECONDS, + () => { + this._loadSnapshot(); + return GLib.SOURCE_CONTINUE; + }); + + this._startMonitoring(); + this._loadSnapshot(); + } + + destroy() { + if (this._reloadSourceId) { + GLib.source_remove(this._reloadSourceId); + this._reloadSourceId = 0; + } + + if (this._refreshSourceId) { + GLib.source_remove(this._refreshSourceId); + this._refreshSourceId = 0; + } + + this._directoryMonitor?.cancel(); + this._directoryMonitor = null; + if (this._showClockedInChangedId) { + this._settings.disconnect(this._showClockedInChangedId); + this._showClockedInChangedId = 0; + } + + super.destroy(); + } + + _startMonitoring() { + this._directoryMonitor?.cancel(); + this._directoryMonitor = null; + + const targetDirectory = GLib.file_test(this._snapshotDir, GLib.FileTest.IS_DIR) + ? this._snapshotDir + : this._snapshotParentDir; + const directory = Gio.File.new_for_path(targetDirectory); + + try { + this._directoryMonitor = directory.monitor_directory( + Gio.FileMonitorFlags.WATCH_MOVES, + null); + this._monitoredDirectory = targetDirectory; + this._directoryMonitor.connect('changed', (_monitor, file, otherFile, eventType) => { + if (!this._eventTouchesSnapshot(file, otherFile, eventType)) + return; + + this._scheduleReload(); + }); + } catch (error) { + this._monitoredDirectory = null; + this._setState({ + status: 'error', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: null, + generatedAt: null, + generatedEpoch: null, + tasks: [], + clockedIn: null, + error: `Unable to monitor ${this._snapshotDir}: ${error.message}`, + }); + } + } + + _eventTouchesSnapshot(file, otherFile, eventType) { + const relevantEvents = new Set([ + Gio.FileMonitorEvent.CHANGED, + Gio.FileMonitorEvent.CHANGES_DONE_HINT, + Gio.FileMonitorEvent.CREATED, + Gio.FileMonitorEvent.MOVED_IN, + Gio.FileMonitorEvent.MOVED_OUT, + Gio.FileMonitorEvent.DELETED, + Gio.FileMonitorEvent.ATTRIBUTE_CHANGED, + ]); + + if (!relevantEvents.has(eventType)) + return false; + + const names = [ + file?.get_basename(), + otherFile?.get_basename(), + ]; + + if (this._monitoredDirectory === this._snapshotParentDir) + return names.includes(this._snapshotDirBaseName); + + return names.includes(this._snapshotBaseName); + } + + _scheduleReload() { + if (this._reloadSourceId) + return; + + this._reloadSourceId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + RELOAD_DELAY_MS, + () => { + this._reloadSourceId = 0; + this._loadSnapshot(); + return GLib.SOURCE_REMOVE; + }); + } + + _loadSnapshot() { + const snapshotFile = Gio.File.new_for_path(this._snapshotPath); + + if ((this._monitoredDirectory === this._snapshotParentDir && + GLib.file_test(this._snapshotDir, GLib.FileTest.IS_DIR)) || + (this._monitoredDirectory === this._snapshotDir && + !GLib.file_test(this._snapshotDir, GLib.FileTest.IS_DIR))) + this._startMonitoring(); + + try { + if (!snapshotFile.query_exists(null)) { + this._setState({ + status: 'missing', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: null, + generatedAt: null, + generatedEpoch: null, + tasks: [], + clockedIn: null, + error: null, + }); + return; + } + + const [, contents] = snapshotFile.load_contents(null); + const payload = JSON.parse(new TextDecoder().decode(contents)); + const currentDate = GLib.DateTime.new_now_local().format('%F'); + + if (payload.date !== currentDate) { + this._setState({ + status: 'stale', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: payload.date ?? null, + generatedAt: payload.generated_at ?? null, + generatedEpoch: payload.generated_epoch ?? null, + tasks: [], + clockedIn: payload.clocked_in ?? null, + error: null, + }); + return; + } + + const tasks = Array.isArray(payload.today_tasks) ? payload.today_tasks : []; + const overdueCount = Number(payload.overdue_count ?? tasks.filter(task => task.is_overdue).length); + const todayCount = Number(payload.today_count ?? tasks.length - overdueCount); + + this._setState({ + status: 'ready', + taskCount: Number(payload.task_count ?? tasks.length), + todayCount, + overdueCount, + date: payload.date ?? null, + generatedAt: payload.generated_at ?? null, + generatedEpoch: payload.generated_epoch ?? null, + tasks, + clockedIn: payload.clocked_in ?? null, + error: null, + }); + } catch (error) { + this._setState({ + status: 'error', + taskCount: 0, + todayCount: 0, + overdueCount: 0, + date: null, + generatedAt: null, + generatedEpoch: null, + tasks: [], + clockedIn: null, + error: error.message, + }); + } + } + + _setState(nextState) { + this._state = nextState; + this._syncLabel(); + this._rebuildMenu(); + } + + _syncLabel() { + switch (this._state.status) { + case 'ready': + this._label.set_text(this._panelText()); + break; + case 'loading': + this._label.set_text('...'); + break; + case 'missing': + this._label.set_text('-'); + break; + default: + this._label.set_text('?'); + break; + } + } + + _rebuildMenu() { + this.menu.removeAll(); + + this.menu.addMenuItem(new InfoMenuItem(this._summaryText(), this._summaryMetaText())); + + if (this._state.error) { + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this.menu.addMenuItem(new InfoMenuItem('Unable to read agenda snapshot', this._state.error)); + return; + } + + if (this._state.status !== 'ready') + return; + + const todayTasks = this._state.tasks.filter(task => !task.is_overdue); + const overdueTasks = this._state.tasks.filter(task => task.is_overdue); + + if (this._showClockedInTask() && this._state.clockedIn) { + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this.menu.addMenuItem(new SectionHeaderItem('Clocked In')); + this.menu.addMenuItem(this._clockedInItem(this._state.clockedIn)); + } + + if (todayTasks.length > 0 || overdueTasks.length > 0) { + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + } + + if (todayTasks.length > 0) { + this.menu.addMenuItem(new SectionHeaderItem('Today')); + for (const task of todayTasks) + this.menu.addMenuItem(this._taskItem(task)); + } + + if (overdueTasks.length > 0) { + if (todayTasks.length > 0) + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this.menu.addMenuItem(new SectionHeaderItem('Overdue')); + for (const task of overdueTasks) + this.menu.addMenuItem(this._taskItem(task)); + } + } + + _panelText() { + const compactCount = this._compactCountText(); + + if (this._showClockedInTask() && this._state.clockedIn?.title) { + const title = this._truncate(this._state.clockedIn.title, PANEL_TITLE_MAX); + return compactCount ? `● ${title} · ${compactCount}` : `● ${title}`; + } + + return compactCount; + } + + _compactCountText() { + const {todayCount, overdueCount} = this._state; + + if (overdueCount > 0 && todayCount > 0) + return `${todayCount}+${overdueCount}!`; + + if (overdueCount > 0) + return `${overdueCount}!`; + + return String(todayCount); + } + + _summaryText() { + switch (this._state.status) { + case 'ready': + if (this._state.taskCount === 0) + return this._state.clockedIn ? 'Clock is running' : 'No scheduled tasks'; + + return this._countPhrase(this._state.todayCount, 'today') + + (this._state.overdueCount > 0 ? `, ${this._countPhrase(this._state.overdueCount, 'overdue')}` : ''); + case 'missing': + return 'Agenda snapshot not found'; + case 'stale': + return `Agenda snapshot is stale (${this._state.date ?? 'unknown date'})`; + case 'loading': + return 'Loading org agenda snapshot'; + default: + return 'Unable to read agenda snapshot'; + } + } + + _summaryMetaText() { + const parts = []; + + if (this._showClockedInTask() && this._state.clockedIn) + parts.push(this._clockSummaryText(this._state.clockedIn)); + + if (this._state.generatedEpoch) + parts.push(`Updated ${this._timeOfDay(this._state.generatedEpoch)}`); + else if (this._state.generatedAt) + parts.push(`Updated ${this._state.generatedAt}`); + + return parts.join(' '); + } + + _showClockedInTask() { + return this._settings.get_boolean('show-clocked-in-task'); + } + + _countPhrase(count, label) { + if (count === 1) + return `1 ${label}`; + + return `${count} ${label}`; + } + + _clockedInItem(task) { + return new InfoMenuItem( + task.title ?? 'Clock running', + this._clockTaskMeta(task)); + } + + _taskItem(task) { + return new InfoMenuItem( + this._taskPrimaryText(task), + this._taskMetaText(task), + {overdue: task.is_overdue}); + } + + _taskPrimaryText(task) { + const pieces = []; + + if (task.time) + pieces.push(task.time); + + pieces.push(task.title ?? 'Untitled task'); + + return pieces.join(' '); + } + + _taskMetaText(task) { + const pieces = []; + + if (task.state) + pieces.push(task.state); + + if (task.category) + pieces.push(task.category); + + if (task.is_overdue && task.scheduled_for) + pieces.push(`scheduled ${this._friendlyDate(task.scheduled_for)}`); + + return pieces.join(' · '); + } + + _clockTaskMeta(task) { + const pieces = []; + + if (task.started_epoch) + pieces.push(`started ${this._timeOfDay(task.started_epoch)}`); + + const elapsed = this._elapsedClockText(task.started_epoch); + if (elapsed) + pieces.push(elapsed); + + if (task.state) + pieces.push(task.state); + + if (task.category) + pieces.push(task.category); + + return pieces.join(' · '); + } + + _clockSummaryText(task) { + const elapsed = this._elapsedClockText(task.started_epoch); + return elapsed ? `Clock ${elapsed}` : 'Clock running'; + } + + _elapsedClockText(startedEpoch) { + if (!startedEpoch) + return ''; + + const roundedMinutes = Math.max(0, Math.floor((Date.now() / 1000 - startedEpoch) / 60)); + const hours = Math.floor(roundedMinutes / 60); + const remainder = roundedMinutes % 60; + + if (hours > 0) + return `${hours}h ${remainder}m`; + + return `${roundedMinutes}m`; + } + + _friendlyDate(dateText) { + const date = GLib.DateTime.new_from_iso8601( + `${dateText}T00:00:00`, + GLib.TimeZone.new_local()); + return date ? date.format('%b %-d') : dateText; + } + + _timeOfDay(epoch) { + const date = GLib.DateTime.new_from_unix_local(epoch); + return date ? date.format('%H:%M') : ''; + } + + _truncate(text, maxLength) { + if (!text || text.length <= maxLength) + return text ?? ''; + + return `${text.slice(0, maxLength - 1)}…`; + } +}); + +export default class OrgAgendaIndicatorExtension extends Extension { + enable() { + this._indicator = new Indicator(this.getSettings()); + Main.panel.addToStatusArea(this.uuid, this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json new file mode 100644 index 0000000..de62ebd --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json @@ -0,0 +1,11 @@ +{ + "uuid": "org-agenda-indicator@rbenencia.name", + "extension-id": "org-agenda-indicator", + "name": "Org Agenda Indicator", + "description": "Show today's org-agenda tasks in the top bar.", + "settings-schema": "org.gnome.shell.extensions.org-agenda-indicator", + "shell-version": ["49"], + "session-modes": ["user"], + "url": "https://rbenencia.name", + "version": 1 +} diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js new file mode 100644 index 0000000..08db40d --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js @@ -0,0 +1,33 @@ +import Adw from 'gi://Adw'; +import Gtk from 'gi://Gtk'; + +import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +export default class OrgAgendaIndicatorPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings('org.gnome.shell.extensions.org-agenda-indicator'); + const page = new Adw.PreferencesPage(); + const group = new Adw.PreferencesGroup({ + title: 'Display', + description: 'Configure what the indicator shows.', + }); + const row = new Adw.ActionRow({ + title: 'Show clocked-in task', + subtitle: 'Display the active Org clock in the panel and menu when one is running.', + }); + const toggle = new Gtk.Switch({ + active: settings.get_boolean('show-clocked-in-task'), + valign: Gtk.Align.CENTER, + }); + + toggle.connect('notify::active', widget => { + settings.set_boolean('show-clocked-in-task', widget.get_active()); + }); + + row.add_suffix(toggle); + row.activatable_widget = toggle; + group.add(row); + page.add(group); + window.add(page); + } +} diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml new file mode 100644 index 0000000..317b9a1 --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml @@ -0,0 +1,14 @@ + + + + + true + Show clocked-in task + + When enabled, the indicator displays the active Org clock in the panel + and in a dedicated menu section. + + + + diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css new file mode 100644 index 0000000..ed690bb --- /dev/null +++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css @@ -0,0 +1,38 @@ +#org-agenda-indicator-label { + margin: 0 6px; + max-width: 24em; +} + +.org-agenda-indicator-overdue { + font-weight: 600; +} + +.org-agenda-indicator-section { + padding: 6px 12px 2px; +} + +.org-agenda-indicator-section-label { + font-size: 0.82em; + font-weight: 700; + letter-spacing: 0.06em; + opacity: 0.7; + text-transform: uppercase; +} + +.org-agenda-indicator-row { + min-width: 22em; + padding: 3px 0; +} + +.org-agenda-indicator-row-title { + font-weight: 600; +} + +.org-agenda-indicator-row-meta { + font-size: 0.9em; + opacity: 0.75; +} + +.org-agenda-indicator-row-title.org-agenda-indicator-overdue { + color: #d97706; +} diff --git a/bin/gnome-set-config b/bin/gnome-set-config index 608c111..a3cc753 100755 --- a/bin/gnome-set-config +++ b/bin/gnome-set-config @@ -5,14 +5,31 @@ NUM_WORKSPACES=9 WORKSPACE_ROUTER_UUID=workspace-router@rbenencia.name +ORG_AGENDA_INDICATOR_UUID=org-agenda-indicator@rbenencia.name + +ensure_extension_enabled() { + uuid="$1" + extension_dir="" + + for candidate in \ + "$HOME/.local/share/gnome-shell/extensions/$uuid" \ + "$HOME/.local/gnome-shell/extensions/$uuid"; do + if [ -d "$candidate" ]; then + extension_dir="$candidate" + break + fi + done + + if [ -z "$extension_dir" ]; then + return 0 + fi -gsettings set org.gnome.mutter dynamic-workspaces false -gsettings set org.gnome.desktop.wm.preferences num-workspaces $NUM_WORKSPACES + if [ -d "$extension_dir/schemas" ] && command -v glib-compile-schemas >/dev/null 2>&1; then + glib-compile-schemas "$extension_dir/schemas" >/dev/null 2>&1 || true + fi -if [ -d "$HOME/.local/share/gnome-shell/extensions/$WORKSPACE_ROUTER_UUID" ] || - [ -d "$HOME/.local/gnome-shell/extensions/$WORKSPACE_ROUTER_UUID" ]; then enabled_extensions=$( - python3 - "$WORKSPACE_ROUTER_UUID" "$(gsettings get org.gnome.shell enabled-extensions)" <<'PY' + python3 - "$uuid" "$(gsettings get org.gnome.shell enabled-extensions)" <<'PY' import ast import sys @@ -28,9 +45,16 @@ PY gsettings set org.gnome.shell enabled-extensions "$enabled_extensions" if command -v gnome-extensions >/dev/null 2>&1; then - gnome-extensions enable "$WORKSPACE_ROUTER_UUID" >/dev/null 2>&1 || true + gnome-extensions enable "$uuid" >/dev/null 2>&1 || true fi -fi +} + +gsettings set org.gnome.mutter dynamic-workspaces false +gsettings set org.gnome.desktop.wm.preferences num-workspaces $NUM_WORKSPACES + +ensure_extension_enabled "$WORKSPACE_ROUTER_UUID" +ensure_extension_enabled "$ORG_AGENDA_INDICATOR_UUID" + # Disable the default p. I don't use it, and it's disruptive when I accidentally trigger it. gsettings set org.gnome.mutter.keybindings switch-monitor '[]' -- cgit v1.2.3