Add list contexts, refactor display logics.

* Refactor formatting logic to better calculate needed padding between
  issues and sections.
* Add `list contexts` command to list all known contexts according to
  the contexts configuration and the contexts defined in issues.
* Be more intentional about when the default context is used. Don't
  override existing context values in issues when changing their state.
* `edit` now allows multiple issues to be edited.
* Change single-issue display to be more verbose, listing all the
  properties and tags on an issue.
This commit is contained in:
Jonathan Bernard 2018-05-29 14:20:40 -05:00
parent 29959a6a8d
commit 2b5f82203c
3 changed files with 122 additions and 76 deletions

View File

@ -11,5 +11,5 @@ bin = @["pit", "pit_api"]
# Dependencies
requires @[ "nim >= 0.18.0", "cliutils 0.3.5", "docopt 0.6.5", "jester 0.2.0",
requires @[ "nim >= 0.18.0", "cliutils 0.4.1", "docopt 0.6.5", "jester 0.2.0",
"timeutils 0.3.0", "uuids 0.1.9" ]

View File

@ -12,12 +12,13 @@ include "pitpkg/private/version.nim"
type
CliContext = ref object
autoList, triggerPtk, verbose: bool
cfg*: PitConfig
contexts*: TableRef[string, string]
defaultContext*, tasksDir*: string
defaultContext*: Option[string]
tasksDir*: string
issues*: TableRef[IssueState, seq[Issue]]
termWidth*: int
triggerPtk*, verbose*: bool
proc initContext(args: Table[string, Value]): CliContext =
let pitCfg = loadConfig(args)
@ -29,9 +30,10 @@ proc initContext(args: Table[string, Value]): CliContext =
let cliCfg = CombinedConfig(docopt: args, json: cliJson)
result = CliContext(
autoList: cliJson.getOrDefault("autoList").getBool(false),
contexts: pitCfg.contexts,
defaultContext: cliJson.getOrDefault("defaultContext").getStr(""),
defaultContext:
if not cliJson.hasKey("defaultContext"): none(string)
else: some(cliJson["defaultContext"].getStr()),
verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"],
issues: newTable[IssueState, seq[Issue]](),
tasksDir: pitCfg.tasksDir,
@ -44,77 +46,93 @@ proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
else: return context.capitalize()
return ctx.contexts[context]
proc writeIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
verbose = false, topPadded = false) =
var showDetails = not issue.details.isNilOrWhitespace and verbose
proc formatIssue(ctx: CliContext, issue: Issue): string =
result = ($issue.id).withColor(fgBlack, true) & "\n"&
issue.summary.withColor(fgWhite) & "\n"
if showDetails and not topPadded: stdout.writeLine("")
if issue.tags.len > 0:
result &= "tags: ".withColor(fgMagenta) &
issue.tags.join(",").withColor(fgGreen, true) & "\n"
if issue.properties.len > 0:
result &= termColor(fgMagenta)
for k, v in issue.properties: result &= k & ": " & v & "\n"
result &= "--------".withColor(fgBlack, true) & "\n"
if not issue.details.isNilOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n"
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
verbose = false): string =
result = ""
var showDetails = not issue.details.isNilOrWhitespace and verbose
# Wrap and write the summary.
var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len)
wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
stdout.setForegroundColor(fgBlack, true)
stdout.write(indent & ($issue.id)[0..<6])
stdout.setForegroundColor(fgWhite, false)
stdout.write(wrappedSummary)
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true)
result &= wrappedSummary.withColor(fgWhite)
if issue.tags.len > 0:
stdout.setForegroundColor(fgGreen, false)
let tagsStr = "(" & issue.tags.join(",") & ")"
if (wrappedSummary.splitLines[^1].len + tagsStr.len + 1) < (width - 2):
stdout.writeLine(" " & tagsStr)
else:
stdout.writeLine("\n" & indent & " " & tagsStr)
else: stdout.writeLine("")
let tagsStr = "(" & issue.tags.join(", ") & ")"
if (result.splitLines[^1].len + tagsStr.len + 1) > (width - 2):
result &= "\n" & indent
result &= " " & tagsStr.withColor(fgGreen)
if issue.hasProp("pending"):
let startIdx = "Pending: ".len
var pendingText = issue["pending"].wordWrap(width - startIdx - 2)
.indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
stdout.setForegroundColor(fgCyan, false)
stdout.writeLine(pendingText)
result &= "\n" & pendingText.withColor(fgCyan)
if showDetails:
stdout.setForegroundColor(fgCyan, false)
stdout.writeLine(issue.details.indent(indent.len + 2))
result &= "\n" & issue.details.strip.indent(indent.len + 2).withColor(fgCyan)
stdout.resetAttributes
result &= termReset
proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false) =
proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int,
indent: string, verbose: bool): string =
result = ""
var topPadded = true
for i in issues:
var issueText = ctx.formatSectionIssue(i, width, indent, verbose)
if issueText.splitLines.len > 1:
if topPadded: result &= issueText & "\n\n"
else: result &= "\n" & issueText & "\n\n"
topPadded = true
else:
result &= issueText & "\n"
topPadded = false
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string =
let innerWidth = ctx.termWidth - (indent.len * 2)
stdout.setForegroundColor(fgBlue, true)
stdout.writeLine(indent & ".".repeat(innerWidth))
stdout.writeLine(state.displayName.center(ctx.termWidth))
stdout.writeLine("")
stdout.resetAttributes
result = termColor(fgBlue) &
(indent & ".".repeat(innerWidth)) & "\n" &
state.displayName.center(ctx.termWidth) & "\n\n" &
termReset
let issuesByContext = issues.groupBy("context")
var topPadded = true
if issues.len > 5 and issuesByContext.len > 1:
for context, ctxIssues in issuesByContext:
topPadded = true
stdout.setForegroundColor(fgYellow, false)
stdout.writeLine(indent & ctx.getIssueContextDisplayName(context) & ":")
stdout.writeLine("")
stdout.resetAttributes
for i in ctxIssues:
ctx.writeIssue(i, innerWidth - 2, indent & " ", verbose, topPadded)
topPadded = not i.details.isNilOrWhitespace and verbose
result &= termColor(fgYellow) &
indent & ctx.getIssueContextDisplayName(context) & ":" &
termReset & "\n\n"
if not topPadded: stdout.writeLine("")
result &= ctx.formatSectionIssueList(ctxIssues, innerWidth - 2, indent & " ", verbose)
result &= "\n"
else:
for i in issues:
ctx.writeIssue(i, innerWidth, indent, verbose, topPadded)
topPadded = not i.details.isNilOrWhitespace and verbose
stdout.writeLine("")
else: result &= ctx.formatSectionIssueList(issues, innerWidth, indent, verbose)
proc loadIssues(ctx: CliContext, state: IssueState) =
ctx.issues[state] = loadIssues(ctx.tasksDir / $state)
@ -169,7 +187,7 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
if state.isSome:
ctx.loadIssues(state.get)
if filter.isSome: ctx.filterIssues(filter.get)
ctx.writeSection(ctx.issues[state.get], state.get, "", verbose)
stdout.write ctx.formatSection(ctx.issues[state.get], state.get, "", verbose)
return
ctx.loadAllIssues()
@ -183,14 +201,14 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
for s in [Current, TodoToday]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
ctx.writeSection(ctx.issues[s], s, indent, verbose)
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
if ctx.issues.hasKey(Done):
let doneIssues = ctx.issues[Done].filterIt(
it.hasProp("completed") and
sameDay(getTime().local, it.getDateTime("completed")))
if doneIssues.len > 0:
ctx.writeSection(doneIssues, Done, indent, verbose)
stdout.write ctx.formatSection(doneIssues, Done, indent, verbose)
# Future items
if future:
@ -198,7 +216,7 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
for s in [Pending, Todo]:
if ctx.issues.hasKey(s) and ctx.issues[s].len > 0:
ctx.writeSection(ctx.issues[s], s, indent, verbose)
stdout.write ctx.formatSection(ctx.issues[s], s, indent, verbose)
when isMainModule:
@ -207,8 +225,8 @@ when isMainModule:
Usage:
pit ( new | add) <summary> [<state>] [options]
pit list [<listable>] [options]
pit ( start | done | pending | do-today | todo | suspend ) <id>... [options]
pit edit <id>
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <id>...
pit ( delete | rm ) <id>...
Options:
@ -258,22 +276,20 @@ Options:
let ctx = initContext(args)
var propertiesOption = none(TableRef[string,string])
var tagsOption = none(seq[string])
if args["--properties"] or args["--context"] or
not ctx.defaultContext.isNilOrWhitespace:
if args["--properties"] or args["--context"]:
var props =
if args["--properties"]: parsePropertiesOption($args["--properties"])
else: newTable[string,string]()
if args["--context"] and $args["--context"] != "all":
props["context"] = $args["--context"]
elif not args["--context"] and not ctx.defaultContext.isNilOrWhitespace:
stderr.writeLine("Limiting to default context: " & ctx.defaultContext)
props["context"] = ctx.defaultContext
if args["--context"]: props["context"] = $args["--context"]
propertiesOption = some(props)
if args["--tags"]: tagsOption = some(($args["--tags"]).split(",").mapIt(it.strip))
## Actual command runners
if args["new"] or args["add"]:
let state =
@ -282,6 +298,9 @@ Options:
var issueProps = propertiesOption.get(newTable[string,string]())
if not issueProps.hasKey("created"): issueProps["created"] = getTime().local.formatIso8601
if not issueProps.hasKey("context") and ctx.defaultContext.isSome():
stderr.writeLine("Using default context: " & ctx.defaultContext.get)
issueProps["context"] = ctx.defaultContext.get
var issue = Issue(
id: genUUID(),
@ -293,17 +312,18 @@ Options:
ctx.tasksDir.store(issue, state)
stdout.writeLine ctx.formatIssue(issue)
elif args["edit"]:
let issueId = $args["<id>"]
for id in @(args["<id>"]):
edit(ctx.tasksDir.loadIssueById(id))
edit(ctx.tasksDir.loadIssueById(issueId))
elif args["start"] or args["do-today"] or args["done"] or
elif args["start"] or args["todo-today"] or args["done"] or
args["pending"] or args["todo"] or args["suspend"]:
var targetState: IssueState
if args["done"]: targetState = Done
elif args["do-today"]: targetState = TodoToday
elif args["todo-today"]: targetState = TodoToday
elif args["pending"]: targetState = Pending
elif args["start"]: targetState = Current
elif args["todo"]: targetState = Todo
@ -314,6 +334,7 @@ Options:
if propertiesOption.isSome:
for k,v in propertiesOption.get:
issue[k] = v
if targetState == Done: issue["completed"] = getTime().local.formatIso8601
issue.changeState(ctx.tasksDir, targetState)
if ctx.triggerPtk:
@ -342,20 +363,49 @@ Options:
let filter = initFilter()
var filterOption = none(IssueFilter)
# Initialize filter with properties (if given)
if propertiesOption.isSome:
filter.properties = propertiesOption.get
filterOption = some(filter)
# If no "context" property is given, use the default (if we have one)
if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get)
filter.properties["context"] = ctx.defaultContext.get
filterOption = some(filter)
# Finally, if the "context" is "all", don't filter on context
if filter.properties.hasKey("context") and
filter.properties["context"] == "all":
filter.properties.del("context")
var listContexts = false
var stateOption = none(IssueState)
var issueIdOption = none(string)
if args["<listable>"]:
try: stateOption = some(parseEnum[IssueState]($args["<listable>"]))
except: issueIdOption = some($args["<listable>"])
if $args["<listable>"] == "contexts": listContexts = true
else:
try: stateOption = some(parseEnum[IssueState]($args["<listable>"]))
except: issueIdOption = some($args["<listable>"])
# List the known contexts
if listContexts:
var uniqContexts = toSeq(ctx.contexts.keys)
ctx.loadAllIssues()
for state, issueList in ctx.issues:
for issue in issueList:
if issue.hasProp("context") and not uniqContexts.contains(issue["context"]):
uniqContexts.add(issue["context"])
for c in uniqContexts: stdout.writeLine(c)
# List a specific issue
if issueIdOption.isSome:
elif issueIdOption.isSome:
let issue = ctx.tasksDir.loadIssueById(issueIdOption.get)
ctx.writeIssue(issue, ctx.termWidth, "", true, true)
stdout.writeLine ctx.formatIssue(issue)
# List all issues
else:
@ -364,10 +414,6 @@ Options:
showBoth or args["--future"],
ctx.verbose)
if ctx.autoList and not args["list"]:
ctx.loadAllIssues()
ctx.list(none(IssueFilter), none(IssueState), true, true, false)
except:
fatal "pit: " & getCurrentExceptionMsg()
#raise getCurrentException()

View File

@ -1 +1 @@
const PIT_VERSION = "4.1.0"
const PIT_VERSION = "4.2.0"