diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/soon.nim | 162 | ||||
| -rw-r--r-- | src/soon/agenda.nim | 58 | ||||
| -rw-r--r-- | src/soon/archive.nim | 60 | ||||
| -rw-r--r-- | src/soon/conditionchecker.nim | 25 | ||||
| -rw-r--r-- | src/soon/config.nim | 28 | ||||
| -rw-r--r-- | src/soon/todos.nim | 38 | ||||
| -rw-r--r-- | src/soon/tui.nim | 378 |
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) |
