aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/soon.nim162
-rw-r--r--src/soon/agenda.nim58
-rw-r--r--src/soon/archive.nim60
-rw-r--r--src/soon/conditionchecker.nim25
-rw-r--r--src/soon/config.nim28
-rw-r--r--src/soon/todos.nim38
-rw-r--r--src/soon/tui.nim378
7 files changed, 647 insertions, 102 deletions
diff --git a/src/soon.nim b/src/soon.nim
index 62dbfcf..e6c545d 100644
--- a/src/soon.nim
+++ b/src/soon.nim
@@ -1,5 +1,5 @@
import std/[os, strutils, strtabs, sequtils, osproc, parseopt]
-import soon/[config, agenda, todos]
+import soon/[config, agenda, todos, tui, archive]
const VERSION = "0.1"
@@ -10,19 +10,20 @@ proc printHelp(badCmd: string) =
Usage: soon [options]
Options:
--a print agenda
--c print reference calendar (using 'cal')
--t print todos
--d print deadlines
--e open default calendar file in $EDITOR
--i open TUI in interactive mode to schedule/archive events
--w, -m, -y print agenda for upcoming week, month, or year
--j print the modified julian day
--i, --date print today's date
--p, --past=DAYS days in past to print agenda (default: 1)
--f, --future=DAYS days in future to print agenda (default: 14)
--h, --help show this help
--v, --version print version
+-a print agenda
+-c print reference calendar (using 'cal')
+-t print todos
+-d print deadlines
+-e open default calendar file in $EDITOR
+-s open soon.conf in $EDITOR
+-i open TUI in interactive mode to schedule/archive events
+-w, -m, -y print agenda for upcoming week, month, or year
+-j print the modified julian day
+-p, --past=DAYS days in past to print agenda (default: 1)
+-f, --future=DAYS days in future to print agenda (default: 14)
+--config <path> use alternate config file
+-h, --help show this help
+-v, --version print version
Check the docs at https://codeberg.org/historia/soon
"""
@@ -55,12 +56,8 @@ proc readAllSoonFiles(path: string): seq[string] =
echo "Check your paths in ~/.config/soon/soon.conf"
quit()
-proc processCalendars(path: string, past: int, future: int) =
- var calendar = readAllSoonFiles(path)
- agenda.printAgenda(calendar, past, future)
- todos.printTodos(calendar)
-
-proc parseArgs(config: StringTableRef): seq[string] =
+proc parseArgs(config: StringTableRef): tuple[commands: seq[string], interactive: bool] =
+ var skipNext = false
for kind, key, val in getopt():
case kind
of cmdEnd: break
@@ -70,15 +67,37 @@ proc parseArgs(config: StringTableRef): seq[string] =
of "future", "f": config["future"] = val
of "help", "h": printHelp("")
of "version", "v": printVersion()
+ of "i":
+ result.interactive = true
+ of "config":
+ if val == "":
+ skipNext = true
else:
- result.add(key)
+ result.commands.add(key)
of cmdArgument:
- printHelp(key)
- if result.len == 0:
- result.add("a")
- result.add("t")
- result.add("d")
- echo "I'm running soon -atd, but I should've checked the user config and run that!"
+ if skipNext:
+ skipNext = false
+ else:
+ printHelp(key)
+
+ let printTodos = config.getOrDefault("printtodos", "true") == "true"
+
+ if result.commands.len == 0:
+ let defaultCmd = config.getOrDefault("defaultCommand", "atd")
+ if result.interactive:
+ for c in defaultCmd:
+ if c == 't' or c == 'd':
+ if printTodos:
+ result.commands.add($c)
+ elif c != ' ':
+ result.commands.add($c)
+ else:
+ for c in defaultCmd:
+ if c == 't' or c == 'd':
+ if printTodos:
+ result.commands.add($c)
+ elif c != ' ':
+ result.commands.add($c)
proc printReference() =
discard execCmd("cal -3")
@@ -89,36 +108,87 @@ proc sanitize(str: string): string =
if ch.isAlphaNumeric() or ch in {'/', '~', '.'}:
result.add(ch)
-proc openEditor(path: string, file: string) =
- var editor = getEnv("EDITOR").sanitize
+proc openFileInEditor(conf: StringTableRef, filePath: string) =
+ var editor = conf.getOrDefault("editor", getEnv("EDITOR")).sanitize
if editor.isEmptyOrWhitespace: editor = "vim"
- let sanePath = path.sanitize
- let saneFile = file.sanitize
- discard execCmd("$1 $2/$3" % [editor, sanePath, saneFile])
+ let saneFile = filePath.sanitize
+ discard execCmd("$1 $2" % [editor, saneFile])
+
+proc openEditor(conf: StringTableRef) =
+ let filePath = conf.getOrDefault("calendarPath",
+ conf.getOrDefault("path", "")) / conf.getOrDefault("defaultFile", "calendar.soon")
+ openFileInEditor(conf, filePath)
+
+proc openConfig(conf: StringTableRef) =
+ openFileInEditor(conf, conf.getOrDefault("configFile",
+ conf.getOrDefault("path", "") / "soon.conf"))
proc validateConfig(config: StringTableRef) =
createDefaultCalendarFile(config["path"] / config["defaultFile"])
-proc processCommands(commands: seq[string], conf: StringTableRef) =
+proc processCommands(commands: seq[string], conf: StringTableRef, interactive: bool) =
var calendar = readAllSoonFiles(conf["calendarPath"])
var past = 0 - parseInt(conf["past"])
var future = parseInt(conf["future"])
- for command in commands:
- case command
- of "a": agenda.printAgenda(calendar, past, future)
- of "w": agenda.printAgenda(calendar, 0, 7)
- of "m": agenda.printAgenda(calendar, 0, 28)
- of "y": agenda.printAgenda(calendar, 0, 365)
- of "t": todos.printTodos(calendar)
- of "d": todos.printDeadlineTodos(calendar)
- of "c": printReference()
- of "e": openEditor(conf["calendarPath"], conf["defaultFile"])
+ let arch = archive.loadArchive(conf.getOrDefault("archiveFile",
+ conf.getOrDefault("path", "") / "archive"))
+
+ if interactive:
+ var showAgenda = false
+ var showTodos = false
+ var showDeadlines = false
+ for cmd in commands:
+ case cmd
+ of "a": showAgenda = true
+ of "w":
+ showAgenda = true
+ past = 0
+ future = 7
+ of "m":
+ showAgenda = true
+ past = 0
+ future = 28
+ of "y":
+ showAgenda = true
+ past = 0
+ future = 365
+ of "t": showTodos = true
+ of "d": showDeadlines = true
+ else: discard
+ if not showAgenda and not showTodos and not showDeadlines:
+ showAgenda = true
+ showTodos = true
+ showDeadlines = true
+ tui.start(calendar, conf, past, future, showAgenda, showTodos, showDeadlines)
+ else:
+ for command in commands:
+ case command
+ of "a": agenda.printAgenda(calendar, past, future, conf, arch)
+ of "w": agenda.printAgenda(calendar, 0, 7, conf, arch)
+ of "m": agenda.printAgenda(calendar, 0, 28, conf, arch)
+ of "y": agenda.printAgenda(calendar, 0, 365, conf, arch)
+ of "t": todos.printTodos(calendar, arch)
+ of "d": todos.printDeadlineTodos(calendar, arch)
+ of "c": printReference()
+ of "e": openEditor(conf)
+ of "s": openConfig(conf)
+ else: discard
+
+proc findConfigArg(): string =
+ let params = commandLineParams()
+ var i = 0
+ while i < params.len:
+ if params[i] == "--config" and i + 1 < params.len:
+ return params[i + 1]
+ elif params[i].startsWith("--config="):
+ return params[i].split("=", maxsplit = 1)[1]
+ inc i
proc main() =
- var conf = config.loadConfig()
- let commands = parseArgs(conf)
+ var conf = config.loadConfig(findConfigArg())
+ let (commands, interactive) = parseArgs(conf)
validateConfig(conf)
- processCommands(commands, conf)
+ processCommands(commands, conf, interactive)
when isMainModule:
main()
diff --git a/src/soon/agenda.nim b/src/soon/agenda.nim
index 1c2ce11..8d43272 100644
--- a/src/soon/agenda.nim
+++ b/src/soon/agenda.nim
@@ -1,13 +1,13 @@
-import std/[times, strutils, sequtils, tables, algorithm]
-import conditionchecker, typechecker, parsetime, columnize
+import std/[times, strutils, sequtils, tables, terminal, strtabs]
+import conditionchecker, typechecker, parsetime, columnize, archive
-type Event = object
- name: string
- time: DateTime
- attachments: seq[string]
+type Event* = object
+ name*: string
+ time*: DateTime
+ attachments*: seq[string]
type Calendar = object
- events: seq[Event]
+ events*: seq[Event]
readyForAttachments: bool
type ConditionTable = Table[string, seq[string]]
@@ -75,18 +75,11 @@ proc processLine(line: string, date: DateTime, cal: Calendar): Calendar =
else:
result = processEventLine(line, date, cal)
-proc getCalendarForDate(cal: seq[string], date: DateTime): Calendar =
+proc getCalendarForDate*(cal: seq[string], date: DateTime): Calendar =
result.readyForAttachments = false
for line in cal:
result = processLine(line, date, result)
-#proc sortTable(t: ConditionTable) =
-# for
-
-# Check nim-lang algorithm basic usage
-proc compare(a, b: string): int =
- cmp(a.len, b.len)
-
proc startWithDate(datePrinted: bool, date: DateTime, format: string): seq[Column] =
if datePrinted:
result.add(Column(text: "", color: fgWhite))
@@ -100,21 +93,46 @@ proc eventTime(event: Event): string =
result = ""
# TODO: Sort me
-proc getDaysEvents(cal: seq[string], date: DateTime, format: string): seq[Line] =
+proc getDaysEvents(cal: seq[string], date: DateTime, format: string,
+ arch: archive.ArchiveData, conf: StringTableRef): seq[Line] =
var events = getCalendarForDate(cal, date).events
var datePrinted = false
+ let showPast = conf.getOrDefault("showPastArchived", "false") == "true"
+ let showFuture = conf.getOrDefault("showFutureArchived", "true") == "true"
+ let today = dateTime(now().year, now().month, now().monthday, 0, 0, 0, 0, local())
+ let isPast = date < today
+ let showArchived = if isPast: showPast else: showFuture
for event in events:
+ let archived = archive.isEventArchived(arch, date, event.name)
+ if archived and not showArchived:
+ continue
var columns = startWithDate(datePrinted, date, format)
datePrinted = true
columns.add(Column(text: eventTime(event), color: fgBlue))
- columns.add(Column(text: event.name, color: fgWhite))
+ var nameColor = fgWhite
+ var nameText = event.name
+ if archived:
+ nameColor = fgWhite
+ nameText = "(Archived) " & event.name
+ columns.add(Column(text: nameText, color: nameColor))
result.add(Line(columns: columns, attachments: event.attachments))
-proc printAgenda*(calendar: seq[string], past: int, future: int) =
+proc printAgenda*(calendar: seq[string], past: int, future: int,
+ conf: StringTableRef, arch: archive.ArchiveData) =
let now = now()
let today = dateTime(now.year, now.month, now.monthday, 00, 00, 00, 00, local())
var agenda = newSeq[Line]()
- for day in past..future:
+ var startDay = past
+ if conf.getOrDefault("alwaysShowUnarchived", "false") == "true":
+ let startDateStr = conf.getOrDefault("startDate", today.format("yyyy-MM-dd"))
+ try:
+ let sd = parse(startDateStr, "yyyy-MM-dd")
+ let daysDiff = (today - sd).inDays
+ if 0 - daysDiff < startDay:
+ startDay = 0 - daysDiff.int
+ except:
+ discard
+ for day in startDay..future:
let curDay = today + day.days
- agenda = agenda.concat(getDaysEvents(calendar, curDay, "ddd yyyy MMM dd"))
+ agenda = agenda.concat(getDaysEvents(calendar, curDay, "ddd yyyy MMM dd", arch, conf))
columnize.echo(agenda)
diff --git a/src/soon/archive.nim b/src/soon/archive.nim
new file mode 100644
index 0000000..fef59e2
--- /dev/null
+++ b/src/soon/archive.nim
@@ -0,0 +1,60 @@
+import std/[os, sets, strutils, times]
+
+type
+ ArchiveData* = object
+ events*: HashSet[string]
+ todos*: HashSet[string]
+
+proc loadArchive*(path: string): ArchiveData =
+ if not fileExists(path):
+ return
+ for line in lines(path):
+ let stripped = line.strip
+ if stripped.len == 0:
+ continue
+ if stripped.startsWith('-'):
+ result.todos.incl(stripped)
+ else:
+ result.events.incl(stripped)
+
+proc archiveEvent*(path: string, date: DateTime, eventName: string) =
+ let line = date.format("yyyy-MM-dd") & "|" & eventName
+ let f = open(path, fmAppend)
+ f.writeLine(line)
+ f.close()
+
+proc unarchiveEvent*(path: string, date: DateTime, eventName: string) =
+ let target = date.format("yyyy-MM-dd") & "|" & eventName
+ if not fileExists(path):
+ return
+ let content = readFile(path)
+ let f = open(path, fmWrite)
+ for line in content.splitLines():
+ if line.strip != target:
+ f.writeLine(line)
+ f.close()
+
+proc completeTodo*(path: string, todoName: string) =
+ let line = "- " & todoName
+ let f = open(path, fmAppend)
+ f.writeLine(line)
+ f.close()
+
+proc uncompleteTodo*(path: string, todoName: string) =
+ let target = "- " & todoName
+ if not fileExists(path):
+ return
+ let content = readFile(path)
+ let f = open(path, fmWrite)
+ for line in content.splitLines():
+ if line.strip != target:
+ f.writeLine(line)
+ f.close()
+
+proc isEventArchived*(archive: ArchiveData, date: DateTime, eventName: string): bool =
+ let key = date.format("yyyy-MM-dd") & "|" & eventName
+ return archive.events.contains(key)
+
+proc isTodoCompleted*(archive: ArchiveData, todoName: string): bool =
+ let key = "- " & todoName
+ return archive.todos.contains(key)
diff --git a/src/soon/conditionchecker.nim b/src/soon/conditionchecker.nim
index eea5afb..8c9284d 100644
--- a/src/soon/conditionchecker.nim
+++ b/src/soon/conditionchecker.nim
@@ -1,4 +1,4 @@
-import std/[times, strutils, tables, re, strtabs]
+import std/[times, strutils, tables, re]
import typechecker
# Forward declarations
@@ -14,7 +14,7 @@ proc expandWeekday(w: string): string =
return weekdays[weekday]
proc expandMonth(m: string): string =
- let month = m.fixcase
+ let month = m.fixCase
if month.len > 3: return month
return parse(month, "MMM").format("MMMM")
@@ -116,7 +116,7 @@ proc conditionToInt(c: string): int =
proc dateGroupStringToTable(s: string): Table[string, int] =
# This used to be s.len-2 for some reason and I don't know why, but it left off the last condition
- let conditions = s[1..s.len-1].splitWhitespace
+ let conditions = s[1..^2].splitWhitespace
var t = initTable[string, int]()
for c in conditions:
t[typechecker.checkType(c)] = conditionToInt(c)
@@ -129,18 +129,23 @@ proc dateGroupStringToDate*(s: string, comparisonDate: DateTime): DateTime =
t["year"] = comparisonDate.year
return dateTime(t["year"], Month(t["month"]), t["day"])
-proc isValidDateGroupRange(left: Table, right: Table): bool =
+proc isValidDateGroupRange(s: string): bool =
+ let dates = s.split('-')
+ let left = dateGroupStringToTable(dates[0])
+ let right = dateGroupStringToTable(dates[1])
for c in ["year", "month", "day"]:
if left.hasKey(c) != right.hasKey(c):
- echo "Error: Left group conditions don't match right group: ", string
+ echo "Error: Left group conditions don't match right group: ", s
return false
if c != "year" and (not left.hasKey(c) or not right.hasKey(c)):
- echo "Error: Missing ", c, " in groups: ", string
+ echo "Error: Missing ", c, " in groups: ", s
return false
return true
# Check that date falls in a range like (Jan 30 2025)-(Feb 2 2025)
proc checkRangeDateGroup(s: string, date: DateTime): bool =
+ if not isValidDateGroupRange(s):
+ return false
let dates = s.split('-')
var left = dateGroupStringToDate(dates[0], date)
var right = dateGroupStringToDate(dates[1], date)
@@ -170,9 +175,9 @@ proc checkOneSidedWeekday(s: string, date: DateTime): bool =
let (left, right) = s.splitRange
let datewd = ord(date.weekday)
if left == "":
- return datewd <= right.weekDayToInt
+ return datewd <= right.weekdayToInt
else:
- return datewd >= left.weekDayToInt
+ return datewd >= left.weekdayToInt
proc checkOneSidedMonth(s: string, date: DateTime): bool =
let (left, right) = s.splitRange
@@ -192,9 +197,9 @@ proc checkOneSidedYear(s: string, date: DateTime): bool =
proc checkOneSidedDateGroup(s: string, date: DateTime): bool =
let (left, right) = s.splitRange
if left == "":
- return date <= dateGroupStringToDate(s[1..s.len-1], date)
+ return date <= dateGroupStringToDate(right, date)
else:
- return date >= dateGroupStringToDate(s[0..s.len-2], date)
+ return date >= dateGroupStringToDate(left, date)
proc checkCond*(cond: string, date: DateTime): bool =
var condition = cond
diff --git a/src/soon/config.nim b/src/soon/config.nim
index 6421baa..38becf5 100644
--- a/src/soon/config.nim
+++ b/src/soon/config.nim
@@ -9,6 +9,7 @@ proc createDefaultConfigFile(path: string) =
config.setSectionKey("Calendar", "past", "0")
config.setSectionKey("Calendar", "future", "14")
config.setSectionKey("Calendar", "editor", getEnv("EDITOR"))
+ config.setSectionKey("Calendar", "defaultCommand", "atd")
config.setSectionKey("Calendar", "", "")
config.setSectionKey("Todo List", "alwaysPrint", "true")
config.setSectionKey("Todo List", "saveCompletedTodos", "false")
@@ -31,23 +32,28 @@ proc loadConfigFromFile(file: string): StringTableRef =
config["past"] = c.getSectionValue("Calendar", "past")
config["future"] = c.getSectionValue("Calendar", "future")
config["editor"] = c.getSectionValue("Calendar", "editor")
+ config["defaultCommand"] = c.getSectionValue("Calendar", "defaultCommand")
config["printtodos"] = c.getSectionValue("Todo List", "alwaysPrint")
config["saveCompletedTodos"] = c.getSectionValue("Todo List", "saveCompletedTodos")
- config["completedFile"] = c.getSectionValue("Todo List", "completedFile")
- config["archiveFile"] = c.getSectionValue("Archive", "file")
- config["showFutureArchived"] = c.getSectionValue("Archive", "showFutureArchived")
- config["showPastArchived"] = c.getSectionValue("Archive", "showPastArchived")
- config["alwaysShowUnarchived"] = c.getSectionValue("Archive", "alwaysShowUnarchived")
+ config["completedFile"] = c.getSectionValue("Todo List", "completedTodoFile")
+ config["archiveFile"] = c.getSectionValue("Archive", "path")
+ config["showFutureArchived"] = c.getSectionValue("Archive", "showFutureArchivedEvents")
+ config["showPastArchived"] = c.getSectionValue("Archive", "showPastArchivedEvents")
+ config["alwaysShowUnarchived"] = c.getSectionValue("Archive", "alwaysShowUnarchivedEvents")
config["startDate"] = c.getSectionValue("Archive", "startDate")
- config["deleteArchivedEvents"] = c.getSectionValue("Archive", "deleteArchivedEvents")
+ config["deleteArchivedEvents"] = c.getSectionValue("Archive", "deleteFromCalendarFile")
config["autoPurge"] = c.getSectionValue("Archive", "autoPurge")
return config
-proc loadConfig*(): StringTableRef =
- let path = expandTilde(getEnv("XDG_CONFIG_HOME", "~/.config")) / "soon"
- let file = path / "soon.conf"
+proc loadConfig*(configPath: string = ""): StringTableRef =
+ let dir = expandTilde(getEnv("XDG_CONFIG_HOME", "~/.config")) / "soon"
+ let file = if configPath != "": configPath else: dir / "soon.conf"
if not fileExists(file):
- createDefaultConfigFile(path)
+ if configPath != "":
+ echo "Config file not found: " & file
+ quit(1)
+ createDefaultConfigFile(dir)
result = loadConfigFromFile(file)
- result["path"] = path
+ result["path"] = dir
+ result["configFile"] = file
diff --git a/src/soon/todos.nim b/src/soon/todos.nim
index cac8c90..a0c1df6 100644
--- a/src/soon/todos.nim
+++ b/src/soon/todos.nim
@@ -1,13 +1,13 @@
import std/[times, strutils, re, options, terminal]
-import conditionchecker, columnize
+import conditionchecker, columnize, archive
type
- Todo = object
- name: string
- attachments: seq[string]
- deadline: DateTime
+ Todo* = object
+ name*: string
+ attachments*: seq[string]
+ deadline*: DateTime
-proc removeHyphen(s: string): string =
+proc removeHyphen*(s: string): string =
return s.strip(trailing = false, chars = {' ', '-'})
proc getFirstNonWhitespaceChar(s: string): char =
@@ -15,7 +15,7 @@ proc getFirstNonWhitespaceChar(s: string): char =
if not (c == ' '):
return c
-proc getTodosFromCalendar(calendar: seq[string]): seq[Todo] =
+proc getTodosFromCalendar*(calendar: seq[string]): seq[Todo] =
var readyForAttachments = false
for line in calendar:
let first = getFirstNonWhitespaceChar(line)
@@ -61,22 +61,30 @@ proc toLine(todo: Todo): Line =
result.attachments = todo.attachments
# TODO: Sort on deadlines
-proc printDeadlineTodos*(calendar: seq[string]) =
+proc printDeadlineTodos*(calendar: seq[string], arch: archive.ArchiveData) =
let todos = getTodosFromCalendar(calendar)
- var (regulars, deadlines) = splitRegularAndDeadlineTodos(todos)
- if deadlines.len == 0: return
+ let (regulars, deadlines) = splitRegularAndDeadlineTodos(todos)
+ var activeDeadlines = newSeq[Todo]()
+ for todo in deadlines:
+ if not archive.isTodoCompleted(arch, removeHyphen(todo.name)):
+ activeDeadlines.add(todo)
+ if activeDeadlines.len == 0: return
echo "\nUpcoming Deadlines:"
var lines = newSeq[Line]()
- for todo in deadlines:
+ for todo in activeDeadlines:
lines.add(todo.toLine)
columnize.echo(lines)
-proc printTodos*(calendar: seq[string]) =
+proc printTodos*(calendar: seq[string], arch: archive.ArchiveData) =
let todos = getTodosFromCalendar(calendar)
- var (regulars, deadlines) = splitRegularAndDeadlineTodos(todos)
- if todos.len == 0: return
- echo "\nTodo List:"
+ let (regulars, deadlines) = splitRegularAndDeadlineTodos(todos)
+ var activeTodos = newSeq[Todo]()
for todo in todos:
+ if not archive.isTodoCompleted(arch, removeHyphen(todo.name)):
+ activeTodos.add(todo)
+ if activeTodos.len == 0: return
+ echo "\nTodo List:"
+ for todo in activeTodos:
echo todo.name
for attachment in todo.attachments:
stdout.styledWriteLine(fgMagenta, attachment)
diff --git a/src/soon/tui.nim b/src/soon/tui.nim
new file mode 100644
index 0000000..67000fc
--- /dev/null
+++ b/src/soon/tui.nim
@@ -0,0 +1,378 @@
+import std/[times, strutils, os, sets, enumerate, strtabs]
+import illwill
+import agenda, todos, archive
+
+type
+ SelectableKind = enum
+ skEvent
+ skTodo
+
+ Selectable = object
+ kind: SelectableKind
+ date: DateTime
+ timeStr: string
+ name: string
+ attachments: seq[string]
+ isArchived: bool
+ newlyArchived: bool
+ newlyCompleted: bool
+
+ LayoutLine = object
+ isHeader: bool
+ isSpacer: bool
+ headerText: string
+ itemIndex: int
+ attachmentIdx: int
+ layoutY: int
+
+proc removeHyphen(s: string): string =
+ s.strip(trailing = false, chars = {' ', '-'})
+
+proc buildItems(cal: seq[string], past: int, future: int,
+ showAgenda: bool, showTodos: bool, showDeadlines: bool,
+ arch: archive.ArchiveData): seq[Selectable] =
+ let today = dateTime(now().year, now().month, now().monthday, 0, 0, 0, 0, local())
+
+ if showAgenda:
+ for day in past..future:
+ let curDay = today + day.days
+ let calForDay = agenda.getCalendarForDate(cal, curDay)
+ for event in calForDay.events:
+ let key = curDay.format("yyyy-MM-dd") & "|" & event.name
+ var ts = ""
+ if event.time.monthday != 1:
+ ts = event.time.format("HH:mm")
+ result.add(Selectable(
+ kind: skEvent,
+ date: curDay,
+ timeStr: ts,
+ name: event.name,
+ attachments: event.attachments,
+ isArchived: arch.events.contains(key),
+ ))
+
+ if showTodos or showDeadlines:
+ let todoList = todos.getTodosFromCalendar(cal)
+ for todo in todoList:
+ let name = removeHyphen(todo.name)
+ var hasDeadline = false
+ for att in todo.attachments:
+ if att.strip.toLower.startsWith("+ due:"):
+ hasDeadline = true
+ break
+ if hasDeadline and not showDeadlines:
+ continue
+ if not hasDeadline and not showTodos:
+ continue
+ result.add(Selectable(
+ kind: skTodo,
+ date: now(),
+ timeStr: "",
+ name: name,
+ attachments: todo.attachments,
+ isArchived: arch.todos.contains("- " & name),
+ ))
+
+# ---- Layout computation ----------------------------------------
+
+proc computeLayout(items: seq[Selectable]): seq[LayoutLine] =
+ var y = 0
+ var hasEvents = false
+ var hasTodos = false
+ for item in items:
+ if item.kind == skEvent: hasEvents = true
+ else: hasTodos = true
+
+ if hasEvents:
+ result.add(LayoutLine(isHeader: true, headerText: "Events:", itemIndex: -1, attachmentIdx: -1, layoutY: y))
+ y += 1
+
+ for i, item in enumerate(items):
+ if item.kind != skEvent: continue
+ result.add(LayoutLine(itemIndex: i, attachmentIdx: -1, layoutY: y))
+ y += 1
+ for j in 0..<item.attachments.len:
+ result.add(LayoutLine(itemIndex: i, attachmentIdx: j, layoutY: y))
+ y += 1
+
+ if hasEvents and hasTodos:
+ result.add(LayoutLine(isSpacer: true, itemIndex: -1, attachmentIdx: -1, layoutY: y))
+ y += 1
+
+ if hasTodos:
+ result.add(LayoutLine(isHeader: true, headerText: "Todo List:", itemIndex: -1, attachmentIdx: -1, layoutY: y))
+ y += 1
+
+ for i, item in enumerate(items):
+ if item.kind != skTodo: continue
+ result.add(LayoutLine(itemIndex: i, attachmentIdx: -1, layoutY: y))
+ y += 1
+ for j in 0..<item.attachments.len:
+ result.add(LayoutLine(itemIndex: i, attachmentIdx: j, layoutY: y))
+ y += 1
+
+proc itemLayoutY(layout: seq[LayoutLine], itemIndex: int): int =
+ for line in layout:
+ if line.itemIndex == itemIndex and line.attachmentIdx < 0:
+ return line.layoutY
+ return 0
+
+# ---- Rendering ------------------------------------------------
+
+const dateFormat = "ddd yyyy MMM dd"
+
+proc drawEventLine(tb: var TerminalBuffer, x, y: int, item: Selectable, active: bool) =
+ let archived = item.isArchived or item.newlyArchived
+ var cx = x
+
+ if active:
+ if archived:
+ tb.setForegroundColor(fgWhite)
+ tb.write(cx, y, "→ ")
+ else:
+ tb.setForegroundColor(fgWhite, bright=true)
+ tb.write(cx, y, "→ ")
+ else:
+ tb.write(cx, y, " ")
+ tb.setStyle({})
+ cx += 2
+
+ if archived and not active:
+ tb.setForegroundColor(fgYellow)
+ tb.setStyle({styleDim})
+ tb.write(cx, y, item.date.format(dateFormat) & " ")
+ tb.setStyle({})
+ else:
+ tb.setForegroundColor(fgYellow)
+ tb.write(cx, y, item.date.format(dateFormat) & " ")
+ cx += 20
+
+ if archived and not active:
+ tb.setForegroundColor(fgBlue)
+ tb.setStyle({styleDim})
+ tb.write(cx, y, item.timeStr & repeat(' ', 7 - item.timeStr.len))
+ tb.setStyle({})
+ else:
+ tb.setForegroundColor(fgBlue)
+ tb.write(cx, y, item.timeStr & repeat(' ', 7 - item.timeStr.len))
+ cx += 7
+
+ if archived:
+ tb.setForegroundColor(fgWhite)
+ tb.setStyle({styleDim})
+ tb.write(cx, y, "(DONE) ")
+ tb.setStyle({})
+ else:
+ tb.setForegroundColor(fgNone)
+ tb.write(cx, y, " ")
+ cx += 8
+
+ if active:
+ if archived:
+ tb.setForegroundColor(fgWhite)
+ tb.write(cx, y, item.name)
+ else:
+ tb.setForegroundColor(fgWhite, bright=true)
+ tb.write(cx, y, item.name)
+ elif archived:
+ tb.setForegroundColor(fgWhite)
+ tb.setStyle({styleDim})
+ tb.write(cx, y, item.name)
+ else:
+ tb.setForegroundColor(fgWhite)
+ tb.write(cx, y, item.name)
+ tb.setStyle({})
+
+proc drawTodoLine(tb: var TerminalBuffer, x, y: int, item: Selectable, active: bool) =
+ let completed = item.isArchived or item.newlyCompleted
+
+ if active:
+ if completed:
+ tb.setForegroundColor(fgWhite)
+ tb.setStyle({styleDim})
+ tb.write(x, y, "→ ")
+ tb.setStyle({})
+ else:
+ tb.setForegroundColor(fgWhite, bright=true)
+ tb.write(x, y, "→ ")
+ else:
+ tb.write(x, y, " ")
+
+ if completed:
+ tb.setForegroundColor(fgWhite)
+ tb.setStyle({styleDim})
+ tb.write(x + 2, y, "- " & item.name)
+ tb.setStyle({})
+ else:
+ tb.setForegroundColor(fgWhite)
+ tb.write(x + 2, y, "- " & item.name)
+
+proc drawAttachment(tb: var TerminalBuffer, x, y: int, text: string, archived: bool) =
+ if archived:
+ tb.setForegroundColor(fgMagenta)
+ tb.setStyle({styleDim})
+ else:
+ tb.setForegroundColor(fgMagenta)
+ tb.write(x, y, text)
+ tb.setStyle({})
+
+proc draw(tb: var TerminalBuffer, items: seq[Selectable], layout: seq[LayoutLine],
+ cursor: int, scrollY: var int) =
+ tb.clear()
+ tb.setForegroundColor(fgNone)
+ tb.setStyle({})
+
+ let screenH = terminalHeight()
+
+ if cursor >= 0:
+ let cy = itemLayoutY(layout, cursor)
+ if cy < scrollY:
+ scrollY = cy
+ elif cy >= scrollY + screenH:
+ scrollY = cy - screenH + 1
+ if scrollY < 0:
+ scrollY = 0
+
+ for line in layout:
+ let screenY = line.layoutY - scrollY
+ if screenY < 0: continue
+ if screenY >= screenH: break
+
+ if line.isHeader:
+ tb.setForegroundColor(fgWhite)
+ tb.write(0, screenY, line.headerText)
+ tb.setForegroundColor(fgNone)
+ elif line.isSpacer:
+ discard
+ elif line.attachmentIdx >= 0:
+ let item = items[line.itemIndex]
+ let archived = item.isArchived or item.newlyArchived
+ let completed = item.isArchived or item.newlyCompleted
+ let dimmed = (item.kind == skEvent and archived) or (item.kind == skTodo and completed)
+ if item.kind == skEvent:
+ drawAttachment(tb, 39, screenY, item.attachments[line.attachmentIdx], dimmed)
+ else:
+ drawAttachment(tb, 4, screenY, item.attachments[line.attachmentIdx], dimmed)
+ else:
+ let item = items[line.itemIndex]
+ let active = line.itemIndex == cursor
+ if item.kind == skEvent:
+ drawEventLine(tb, 0, screenY, item, active)
+ else:
+ drawTodoLine(tb, 0, screenY, item, active)
+
+ if items.len > 0 and cursor >= 0:
+ let posText = " " & $(cursor + 1) & "/" & $items.len & " "
+ tb.setForegroundColor(fgNone)
+ tb.write(max(0, terminalWidth() - posText.len), screenH - 1, posText)
+
+ tb.display()
+
+# ---- Persistence ----------------------------------------------
+
+proc getFirstNonWhitespaceChar(s: string): char =
+ for c in s:
+ if c != ' ':
+ return c
+ return '\0'
+
+proc findAndRemoveTodo(calDir: string, todoName: string) =
+ let strippedName = todoName.strip(chars = {' ', '-'})
+ for file in walkDirRec(calDir):
+ if not file.toLower().endsWith(".soon"):
+ continue
+ var lines = readFile(file).splitLines(keepEol = false)
+ var newLines: seq[string]
+ var skip = false
+ var found = false
+ for line in lines:
+ let stripped = line.strip
+ let first = getFirstNonWhitespaceChar(stripped)
+ if first == '-' and removeHyphen(stripped) == strippedName:
+ found = true
+ skip = true
+ continue
+ elif first == '+' and skip:
+ continue
+ else:
+ skip = false
+ newLines.add(line)
+ if found:
+ var f = open(file, fmWrite)
+ for nl in newLines:
+ f.writeLine(nl)
+ f.close()
+ return
+
+# ---- Main TUI loop --------------------------------------------
+
+proc start*(cal: seq[string], conf: StringTableRef,
+ past: int, future: int,
+ showAgenda: bool, showTodos: bool, showDeadlines: bool) =
+ illwillInit(fullScreen=true)
+ defer: illwillDeinit()
+
+ let archiveFile = conf.getOrDefault("archiveFile",
+ conf.getOrDefault("path", "") / "archive")
+ let calDir = conf.getOrDefault("calendarPath",
+ conf.getOrDefault("path", ""))
+
+ var arch = archive.loadArchive(archiveFile)
+
+ var items = buildItems(cal, past, future, showAgenda, showTodos, showDeadlines, arch)
+ var layout = computeLayout(items)
+
+ var cursor = 0
+ if items.len == 0:
+ cursor = -1
+
+ var scrollY = 0
+ var tb = newTerminalBuffer(terminalWidth(), terminalHeight())
+
+ if items.len == 0:
+ tb.clear()
+ tb.setForegroundColor(fgWhite)
+ tb.write(0, 0, "Nothing to display. Press q to quit.")
+ tb.display()
+ while getKey() != Key.Q and getKey() != Key.Escape:
+ discard
+ return
+
+ var running = true
+ while running:
+ if tb.width != terminalWidth() or tb.height != terminalHeight():
+ tb = newTerminalBuffer(terminalWidth(), terminalHeight())
+ draw(tb, items, layout, cursor, scrollY)
+
+ let key = getKeyWithTimeout(50)
+ case key:
+ of Key.None: discard
+ of Key.Q, Key.Escape:
+ running = false
+ of Key.Up, Key.K:
+ if cursor > 0: cursor -= 1
+ of Key.Down, Key.J:
+ if cursor < items.len - 1: cursor += 1
+ of Key.Space, Key.D, Key.X:
+ if cursor >= 0 and cursor < items.len:
+ case items[cursor].kind:
+ of skEvent:
+ if items[cursor].isArchived or items[cursor].newlyArchived:
+ items[cursor].newlyArchived = false
+ archive.unarchiveEvent(archiveFile, items[cursor].date, items[cursor].name)
+ else:
+ items[cursor].newlyArchived = true
+ archive.archiveEvent(archiveFile, items[cursor].date, items[cursor].name)
+ of skTodo:
+ if items[cursor].isArchived or items[cursor].newlyCompleted:
+ items[cursor].newlyCompleted = false
+ archive.uncompleteTodo(archiveFile, items[cursor].name)
+ else:
+ items[cursor].newlyCompleted = true
+ archive.completeTodo(archiveFile, items[cursor].name)
+ else:
+ discard
+
+ for item in items:
+ if item.kind == skTodo and item.newlyCompleted:
+ findAndRemoveTodo(calDir, item.name)