293 lines
10 KiB
QML
293 lines
10 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Layouts
|
||
|
||
// A compact date picker for a section's SCHEDULED/DEADLINE planning
|
||
// timestamp. Supports the common point-timestamp forms the parser handles:
|
||
// a date, an optional time (HH:MM), and an optional repeater (e.g. +1w).
|
||
// Planning timestamps are active by org convention, so the server wraps the
|
||
// value in <...>. Set writes the field, Clear removes it.
|
||
//
|
||
// Open with open(row, field, currentValue), where field is "SCHEDULED" or
|
||
// "DEADLINE" and currentValue is the field's current <...> string (may be
|
||
// empty). The dialog parses currentValue to prefill its controls.
|
||
Dialog {
|
||
id: dpd
|
||
title: qsTr("Set Date")
|
||
modal: true
|
||
standardButtons: Dialog.NoButton
|
||
width: 340
|
||
|
||
x: (parent.width - width) / 2
|
||
y: (parent.height - height) / 2
|
||
|
||
// Target section row and planning field.
|
||
property int targetRow: -1
|
||
property string field: "SCHEDULED"
|
||
|
||
// Mode: "planning" writes a SCHEDULED/DEADLINE field; "insert" emits the
|
||
// built timestamp for a caller to insert (e.g. into a body editor at the
|
||
// cursor). In insert mode `insertActive` chooses <...> vs [...].
|
||
property string mode: "planning"
|
||
property bool insertActive: true
|
||
signal timestampAccepted(string ts)
|
||
|
||
// Calendar state.
|
||
property int shownMonth: (new Date()).getMonth() // 0-11
|
||
property int shownYear: (new Date()).getFullYear()
|
||
// Selected date components (-1 day = none selected yet).
|
||
property int selYear: -1
|
||
property int selMonth: -1
|
||
property int selDay: -1
|
||
|
||
readonly property var monthNames: [
|
||
qsTr("January"), qsTr("February"), qsTr("March"), qsTr("April"),
|
||
qsTr("May"), qsTr("June"), qsTr("July"), qsTr("August"),
|
||
qsTr("September"), qsTr("October"), qsTr("November"), qsTr("December")
|
||
]
|
||
readonly property var dayNames: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||
|
||
background: Rectangle {
|
||
color: theme.windowColor
|
||
border.color: theme.midColor
|
||
border.width: 1
|
||
radius: 6
|
||
}
|
||
|
||
// Open the picker for a given row/field, prefilled from currentValue.
|
||
function open2(row, fieldName, currentValue) {
|
||
mode = "planning"
|
||
targetRow = row
|
||
field = fieldName
|
||
parseValue(currentValue)
|
||
open()
|
||
}
|
||
|
||
// Open the picker in insert mode. On Set it emits timestampAccepted with
|
||
// the built <...> (or [...]) timestamp instead of writing a field.
|
||
function openInsert() {
|
||
mode = "insert"
|
||
parseValue("")
|
||
open()
|
||
}
|
||
|
||
// Parse an org timestamp like "<2025-01-15 Wed 10:30 +1w>" into the
|
||
// dialog's controls. An empty/unparseable value leaves today selected and
|
||
// the time/repeater empty.
|
||
function parseValue(v) {
|
||
timeField.text = ""
|
||
repeaterField.text = ""
|
||
var now = new Date()
|
||
selYear = now.getFullYear(); selMonth = now.getMonth(); selDay = now.getDate()
|
||
if (v && v.length > 0) {
|
||
// Strip surrounding < > or [ ].
|
||
var inner = v.replace(/^[<\[]/, "").replace(/[>\]]$/, "").trim()
|
||
var dm = inner.match(/^(\d{4})-(\d{2})-(\d{2})/)
|
||
if (dm) {
|
||
selYear = parseInt(dm[1]); selMonth = parseInt(dm[2]) - 1; selDay = parseInt(dm[3])
|
||
}
|
||
var tm = inner.match(/\b(\d{1,2}:\d{2})\b/)
|
||
if (tm) timeField.text = tm[1]
|
||
var rm = inner.match(/((?:\+\+|\.\+|\+)\d+[hdwmy])/)
|
||
if (rm) repeaterField.text = rm[1]
|
||
}
|
||
shownMonth = selMonth
|
||
shownYear = selYear
|
||
}
|
||
|
||
function prevMonth() {
|
||
if (shownMonth === 0) { shownMonth = 11; shownYear-- } else shownMonth--
|
||
}
|
||
function nextMonth() {
|
||
if (shownMonth === 11) { shownMonth = 0; shownYear++ } else shownMonth++
|
||
}
|
||
|
||
// Build the inner timestamp string "YYYY-MM-DD Day [HH:MM] [repeater]".
|
||
function buildTimestamp() {
|
||
if (selYear < 0) return ""
|
||
var mm = (selMonth + 1 < 10 ? "0" : "") + (selMonth + 1)
|
||
var dd = (selDay < 10 ? "0" : "") + selDay
|
||
var dow = dayNames[(new Date(selYear, selMonth, selDay)).getDay()]
|
||
var s = selYear + "-" + mm + "-" + dd + " " + dow
|
||
var t = timeField.text.trim()
|
||
if (/^\d{1,2}:\d{2}$/.test(t)) s += " " + t
|
||
var r = repeaterField.text.trim()
|
||
if (/^(?:\+\+|\.\+|\+)\d+[hdwmy]$/.test(r)) s += " " + r
|
||
return s
|
||
}
|
||
|
||
function applySet() {
|
||
var ts = buildTimestamp()
|
||
if (ts.length === 0) { close(); return }
|
||
if (mode === "insert") {
|
||
var wrapped = insertActive ? "<" + ts + ">" : "[" + ts + "]"
|
||
timestampAccepted(wrapped)
|
||
close()
|
||
return
|
||
}
|
||
if (field === "DEADLINE")
|
||
docModel.setDeadline(targetRow, ts)
|
||
else
|
||
docModel.setScheduled(targetRow, ts)
|
||
close()
|
||
}
|
||
|
||
function applyClear() {
|
||
if (field === "DEADLINE")
|
||
docModel.setDeadline(targetRow, "")
|
||
else
|
||
docModel.setScheduled(targetRow, "")
|
||
close()
|
||
}
|
||
|
||
contentItem: ColumnLayout {
|
||
spacing: 10
|
||
|
||
// Mode-dependent selector: Scheduled/Deadline (planning) or
|
||
// Active/Inactive (insert).
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 6
|
||
visible: dpd.mode === "planning"
|
||
RadioButton {
|
||
text: qsTr("Scheduled")
|
||
checked: dpd.field === "SCHEDULED"
|
||
onClicked: dpd.field = "SCHEDULED"
|
||
}
|
||
RadioButton {
|
||
text: qsTr("Deadline")
|
||
checked: dpd.field === "DEADLINE"
|
||
onClicked: dpd.field = "DEADLINE"
|
||
}
|
||
}
|
||
|
||
// Insert-mode selector: active <...> vs inactive [...].
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 6
|
||
visible: dpd.mode === "insert"
|
||
RadioButton {
|
||
text: qsTr("Active <>")
|
||
checked: dpd.insertActive
|
||
onClicked: dpd.insertActive = true
|
||
}
|
||
RadioButton {
|
||
text: qsTr("Inactive []")
|
||
checked: !dpd.insertActive
|
||
onClicked: dpd.insertActive = false
|
||
}
|
||
}
|
||
|
||
// Month/year navigation.
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
Button { text: "‹"; onClicked: dpd.prevMonth() }
|
||
Label {
|
||
Layout.fillWidth: true
|
||
horizontalAlignment: Text.AlignHCenter
|
||
text: dpd.monthNames[dpd.shownMonth] + " " + dpd.shownYear
|
||
color: theme.textColor
|
||
font.bold: true
|
||
}
|
||
Button { text: "›"; onClicked: dpd.nextMonth() }
|
||
}
|
||
|
||
DayOfWeekRow {
|
||
Layout.fillWidth: true
|
||
locale: grid.locale
|
||
delegate: Label {
|
||
text: model.shortName
|
||
color: theme.placeholderTextColor
|
||
font.pointSize: Math.max(8, (theme.fontSize > 0 ? theme.fontSize : 11) - 2)
|
||
horizontalAlignment: Text.AlignHCenter
|
||
}
|
||
}
|
||
|
||
MonthGrid {
|
||
id: grid
|
||
Layout.fillWidth: true
|
||
month: dpd.shownMonth
|
||
year: dpd.shownYear
|
||
|
||
delegate: Rectangle {
|
||
required property var model
|
||
readonly property bool inMonth: model.month === dpd.shownMonth
|
||
readonly property bool isSelected:
|
||
model.year === dpd.selYear && model.month === dpd.selMonth
|
||
&& model.day === dpd.selDay
|
||
implicitWidth: 36
|
||
implicitHeight: 28
|
||
radius: 4
|
||
color: isSelected ? theme.highlightColor
|
||
: (dayHover.hovered && inMonth ? theme.alternateBaseColor
|
||
: "transparent")
|
||
Label {
|
||
anchors.centerIn: parent
|
||
text: model.day
|
||
color: !inMonth ? theme.placeholderTextColor
|
||
: (parent.isSelected ? theme.highlightedTextColor
|
||
: theme.textColor)
|
||
font.pointSize: theme.fontSize > 0 ? theme.fontSize - 1 : 10
|
||
}
|
||
HoverHandler { id: dayHover }
|
||
TapHandler {
|
||
onTapped: {
|
||
dpd.selYear = model.year
|
||
dpd.selMonth = model.month
|
||
dpd.selDay = model.day
|
||
// Follow the click into adjacent months.
|
||
dpd.shownMonth = model.month
|
||
dpd.shownYear = model.year
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Optional time and repeater.
|
||
GridLayout {
|
||
Layout.fillWidth: true
|
||
columns: 2
|
||
columnSpacing: 8
|
||
rowSpacing: 6
|
||
|
||
Label { text: qsTr("Time"); color: theme.textColor }
|
||
TextField {
|
||
id: timeField
|
||
Layout.fillWidth: true
|
||
placeholderText: qsTr("HH:MM (optional)")
|
||
inputMask: ""
|
||
selectByMouse: true
|
||
}
|
||
|
||
Label { text: qsTr("Repeat"); color: theme.textColor }
|
||
TextField {
|
||
id: repeaterField
|
||
Layout.fillWidth: true
|
||
placeholderText: qsTr("+1w / ++1m / .+2d (optional)")
|
||
selectByMouse: true
|
||
}
|
||
}
|
||
|
||
// Actions.
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 6
|
||
Button {
|
||
text: qsTr("Clear")
|
||
visible: dpd.mode === "planning"
|
||
onClicked: dpd.applyClear()
|
||
}
|
||
Item { Layout.fillWidth: true }
|
||
Button {
|
||
text: qsTr("Cancel")
|
||
onClicked: dpd.close()
|
||
}
|
||
Button {
|
||
text: dpd.mode === "insert" ? qsTr("Insert") : qsTr("Set")
|
||
highlighted: true
|
||
onClicked: dpd.applySet()
|
||
}
|
||
}
|
||
}
|
||
}
|