from typing import Optional, List, Dict, Tuple, Union, Any
try:
from questionary import unsafe_prompt, Separator, Style
from questionary.prompts.common import print_formatted_text
import questionary.constants
except ImportError as e:
raise ImportError(f"Extra CLI dependencies not met: {str(e)}") from None
import re
from textwrap import wrap
from .task import TaskList, Task, TaskTree, TaskError
from .api import FetchMode
class PromptSeparator(Separator):
def __init__(self) -> None:
super().__init__('─' * 15)
_style: Style = Style(questionary.constants.DEFAULT_STYLE.style_rules + [
("aborting", "fg:ansibrightblack"),
("answer", "fg:ansibrightblue bold"),
("disabled", "fg:ansibrightblack"),
("highlighted", "fg:ansibrightblue"),
("instruction", "fg:ansibrightblack"),
("pointer", "fg:ansiyellow"),
("qmark", "fg:ansiyellow"),
("question", "bold"),
("selected", "fg:ansibrightblue"),
("separator", "fg:ansibrightblack"),
("text", ""),
("validation", "fg:ansibrightblue"),
])
def _get_term_width() -> Optional[int]:
import os
import sys
import fcntl
import struct
try:
return int(os.getenv("COLUMNS", None)) # type: ignore
except (ValueError, TypeError):
pass
for fd in [sys.stdout.fileno(), sys.stderr.fileno()]:
try:
# TODO: handle SIGWINCH and cache
ws_st: bytes = fcntl.ioctl(fd, 21523, b"\x00\x00" * 4) # TIOCGWINSZ
return struct.unpack("hhhh", ws_st)[1] # ws_row, ws_col, ws_xpixel, ws_ypixel shorts
except (OSError, struct.error):
pass
return None
def _style_str(class_name: str) -> Optional[str]:
for class_names, style_str in reversed(_style.style_rules):
if class_name in class_names.lower().split():
return style_str
return None
def print_err(text: str) -> None:
print_formatted_text("! " + text, style=_style_str("aborting"))
def print_value(key: str, value: str) -> None:
print_formatted_text(" " + key, style=_style_str("highlighted"), end=" ")
print_formatted_text(value, style=_style_str("text"))
def print_text(text: Optional[str]) -> None:
text = text.rstrip() if text is not None else ''
columns: Optional[int] = _get_term_width()
for line in text.splitlines(keepends=False):
line = line.replace("\t", " " * 4).rstrip()
indent: int = re.match(r"^[ >*+-]*", line).end() # type: ignore
for wrapped_line in wrap(line, width=columns,
initial_indent=" ", subsequent_indent=" " * (2 + indent),
replace_whitespace=True, drop_whitespace=False) if columns and line else [" " + line]:
print_formatted_text(wrapped_line, style=_style_str("text"))
def safe_prompt(questions: List[Dict[str, Any]], **kwargs) -> Dict[str, Any]:
try:
return unsafe_prompt(questions, style=_style, **kwargs)
except KeyboardInterrupt:
print_err("Cancelled")
return {}
def prompt_password() -> Optional[str]:
answers: Dict = safe_prompt([{
'type': 'password',
'name': 'password',
'message': 'Password',
}])
return answers.get('password', None)
def prompt_text(message: str, default: Optional[str] = None, validate: Optional[str] = None) -> Optional[str]:
answers = safe_prompt([{
'type': 'text',
'name': 'content',
'message': message,
'default': default if default is not None else '',
'validate': (lambda _: re.fullmatch(validate, _) is not None) if validate is not None else None
}])
return answers.get('content', '').strip() or None
def prompt_tasklist(lists: List[TaskList], default: Optional[TaskList] = None) -> Optional[Union[str, TaskList]]:
menu: List[Union[Separator, Dict[str, Any]]] = [{'name': '[exit]', 'value': ''},
{'name': '[mode]', 'value': 'mode'},
{'name': 'Add list', 'value': 'new'},
PromptSeparator()]
choices: List[Dict[str, Any]] = [{'name': _.name or "???", 'value': _} for _ in lists]
default_index: int = 0
if default is not None:
for i, _ in enumerate(lists):
if _.href == default.href:
default_index = i
break
answers: Dict = safe_prompt([{
'type': 'list',
'name': 'tasklist',
'message': 'Task list',
'choices': [*menu, *choices],
'default': choices[default_index] if 0 <= default_index < len(choices) else None
}])
return answers.get('tasklist', None) or None
def prompt_fetch_mode(default: FetchMode) -> FetchMode:
answers: Dict = safe_prompt([{
'type': 'list',
'name': 'completed',
'message': 'Task filter',
'choices': [{'name': _.value, 'value': _} for _ in list(FetchMode)],
'default': {'name': default.value, 'value': default}
}])
return answers.get('completed', default)
def prompt_task(tasks: TaskTree, default: Optional[int] = None) -> Optional[Union[str, Task]]:
def task_label(d: int, t: Task) -> str:
return "{} {} {}{}".format(
' ' * d,
questionary.constants.INDICATOR_SELECTED if t.completed is not None else
questionary.constants.INDICATOR_UNSELECTED,
str(t.summary).strip(),
' […]' if t.description else '',
)
menu: List[Union[Separator, Dict[str, Any]]] = [{'name': '[back]', 'value': ''},
{'name': 'Add task', 'value': 'new'},
{'name': 'Rename list', 'value': 'edit'},
{'name': 'Delete list', 'value': 'delete'},
PromptSeparator()]
try:
choices: List[Dict[str, Any]] = [{'name': task_label(depth, task), 'value': task}
for depth, task in tasks.tasks()]
except (ValueError, KeyError) as e:
raise TaskError(f"Cannot parse task for label: {str(e)}") from None
answers: Dict = safe_prompt([{
'type': 'list',
'name': 'task',
'message': 'Task',
'choices': [*menu, *choices],
'default': choices[default]
if default is not None and 0 <= default < len(choices)
else choices[0] if len(choices) else None,
}, {
'type': 'confirm',
'when': lambda _: _.get('task', '') == 'delete',
'name': 'confirm',
'message': 'Confirm delete',
'default': False,
}])
answer: Optional[Union[str, Task]] = answers.get('task', None) or None
if isinstance(answer, str) and answer == 'delete':
return answer if answers.get('confirm', False) else None
else:
return answer
def prompt_task_op() -> Optional[str]:
answers: Dict = safe_prompt([{
'type': 'list',
'name': 'operation',
'message': 'Edit task',
'choices': [
{'name': '[back]', 'value': ''},
{'name': 'Toggle (un)completed', 'value': 'complete'},
{'name': 'Edit task', 'value': 'edit'},
{'name': 'Add subtask', 'value': 'subtask'},
{'name': 'Delete task', 'value': 'delete'},
]
}, {
'type': 'confirm',
'when': lambda _: _.get('operation', '') == 'delete',
'name': 'confirm',
'message': 'Confirm delete',
'default': False,
}])
operation: Optional[str] = answers.get('operation', None) or None
if operation == 'delete':
return operation if answers.get('confirm', False) else None
else:
return operation
def prompt_content(content: Optional[str]) -> Optional[str]:
answers = safe_prompt([{
'type': 'text', # editor
'name': 'content',
'message': 'First line summary, rest description',
'default': content if content is not None else '',
'multiline': True,
}])
new_content: str = answers.get('content', '').strip()
if not new_content:
return None
elif content and new_content == content.strip():
return None
else:
return new_content
def prompt_task_content(summary: Optional[str], description: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
content: Optional[str] = None
if summary or description:
content = "\n".join([
summary or '',
description or ''
])
new_content: Optional[str] = prompt_content(content)
if new_content is None:
return None, None
summary_desc: List[str] = new_content.split("\n", maxsplit=1)
if len(summary_desc) > 1:
return summary_desc[0].strip(), summary_desc[1].rstrip()
else:
return summary_desc[0].strip(), None