Basic curses functionality - show categories and scroll list, handle

term resizing
This commit is contained in:
asdf 2021-08-06 21:11:26 +10:00
parent e66d793646
commit 8fe9af529b
2 changed files with 159 additions and 110 deletions

View File

@ -4,7 +4,8 @@
## If this script contains bugs, blame cmccabe.
import getpass
import readline
# import readline
import signal
import subprocess
import sys
@ -14,11 +15,16 @@ from urllib.parse import urlparse
from datetime import datetime
from shutil import which, get_terminal_size
from math import ceil
import curses
import locale
import data
import config
locale.setlocale(locale.LC_ALL, "")
code = locale.getpreferredencoding()
## id (if parent), username, datestamp, parent-id, category, link-url, link-title
LinkData = data.LinkData()
link_data: list = LinkData.link_data
@ -30,27 +36,30 @@ def print_page_count(pages):
print("Page {} of {}".format(pages.current, pages.count))
def print_categories(categories, pages):
def print_categories(categories, cols) -> tuple[str]:
"""Prints the list of categories with an indicator for new activity"""
header = "\n{:>4s} New {:<25s}".format("ID#", "Category")
out = ""
output: list[str] = []
for i, record in enumerate(categories[pages.current_slice]):
out += "{:4d} {} {} ({})\n".format(
i + 1,
"x" if record["last_updated"] >= config.USER.lastlogin else " ",
record["name"],
record["count"],
output.append("{:>4s} New {}".format("ID#", "Category"))
newmarker = "*"
namelen = cols - 11
for i, record in enumerate(categories):
index_number = i + 1
name = textwrap.shorten(record["name"], width=namelen, placeholder="...")
output.append(
"{:4d} {} {} ({})".format(
index_number,
newmarker if record["last_updated"] >= config.USER.lastlogin else " ",
name,
record["count"],
)
)
print("\033c", end="")
print_banner()
if len(out) > 0:
print(header)
print(out)
print_page_count(pages)
else:
print("\n There are no posts yet - enter p to post a new link\n")
if len(output) < 2:
output = "\n There are no posts yet - enter p to post a new link\n".splitlines()
return output
def print_category_details(category_details, pages):
@ -94,7 +103,6 @@ def print_category_details(category_details, pages):
namelen=namelen,
)
print("\033c", end="")
if len(out) > 0:
print(header)
print("." * len(header))
@ -113,7 +121,6 @@ def print_thread_details(thread_details, pages) -> tuple:
)
# post detail view
print("\033c", end="")
print(
"\n\n{:<17}: {}".format(
style_text("Title", False, "bold"), thread_details["title"]
@ -277,7 +284,7 @@ def is_valid_input(entry: str) -> bool:
def is_correct_category(entry: str) -> bool:
"""Make sure the user purposefully created a new category and not by
accident (mistyped, tried to use category number instead of name)"""
if entry not in [record["name"] for record in categories]:
if entry not in [record["name"] for record in LinkData.categories]:
question = "Do you want to create a new category '{}'? Y/[N]".format(entry)
answer = input(question).lower().strip()
return answer != "" and answer[0] == "y"
@ -294,8 +301,8 @@ def get_input(item: str) -> str:
if i == "?":
category_text = (
"No categories yet, feel free to make a new one!"
if not categories
else ", ".join(sorted(record["name"] for record in categories))
if not LinkData.categories
else ", ".join(sorted(record["name"] for record in LinkData.categories))
)
print(
"Available categories: {}\n"
@ -340,10 +347,10 @@ def post_link() -> int:
return LinkData.add(record)
class Pages:
class Page:
def __init__(self):
self.current: int = 1 # the current page
self.count: int = 0 # the total number of pages
self.count: int = 0 # the total count of pages
self.length: int = None # the number of lines on a page
self.current_slice: slice = None # a slice to display the current page data
@ -371,7 +378,7 @@ class Level:
def __init__(self, name):
self.name = name
self.data = None
self.pages = Pages()
self.pages = Page()
self.selected_index = None
@ -395,6 +402,9 @@ class Menu:
self.current_level = self.main_levels[self.main_level_index]
def back(self):
# TODO: back should probably go to previous pages before going up a
# level? main controls could be next page and "back" which would be a
# combo of previous page and go back a level
if self.is_main_level:
if self.main_level_index > 0:
self.main_level_index -= 1
@ -419,7 +429,76 @@ class Menu:
self.current_level = self.main_levels[self.main_level_index]
def menu_view_categories():
def new_main_menu(stdscr):
# curses.use_default_colors()
# curses.curs_set(0)
title = "Linkulator".encode(code)
body = print_categories(LinkData.categories, curses.COLS)
status = "".encode(code)
action = "waiting".encode(code)
bodyminy = 2
bodyscrollmin = 0
while True:
# print title
stdscr.addstr(0, 1, title)
stdscr.clrtoeol()
# print body
bodymaxy = curses.LINES - 2
viewslice = slice(bodyscrollmin, bodyscrollmin + bodymaxy)
count = 0
for i, lines in enumerate(body[viewslice]):
count += count
stdscr.addstr(i + bodyminy, 0, lines.encode(code))
stdscr.clrtoeol()
for i in range((bodymaxy) - count):
stdscr.clrtoeol()
stdscr.refresh()
# print status
stdscr.addstr((curses.LINES - 1), 0, status)
stdscr.clrtoeol()
try:
k = stdscr.getkey()
except (curses.error):
status = str(curses.error)
k = None
continue
if k == "q":
graceful_exit()
elif k == "KEY_RESIZE":
stdscr.clear()
curses.resizeterm(*stdscr.getmaxyx())
curses.flushinp()
curses.update_lines_cols()
action = "resize"
elif k == "j":
if (bodyscrollmin <= (len(body) - bodymaxy)):
bodyscrollmin += 1
action = "down"
else:
action = "tried down"
elif k == "k":
if bodyscrollmin > 0:
bodyscrollmin -= 1
action = "up"
else:
action = "tried up"
status = "{}".format(action)
status.encode(code)
stdscr.refresh()
def main_menu():
"""Displays list of categories, takes keyboard input and
executes corresponding functions."""
@ -576,82 +655,6 @@ def parse_input() -> str:
# return input(input_text).lower()
def menu_view_category_details(selected_category):
"""Displays category details, takes keyboard input and executes
corresponding functions"""
pages = Pages()
category_details = LinkData.list_category_details(selected_category)
while True:
boilerplate_len = 10
pages = calculate_pages(len(category_details), boilerplate_len, pages)
pages = get_pages_current_slice(pages)
print_category_details(
category_details[current_page],
pages.current,
pages.count,
)
pages = interpret_commands(pages, "category_details")
if option == "q":
graceful_exit()
if option == "m":
return
if option == "s":
search()
continue
if option == "p":
post_id = post_link()
if post_id >= 0:
menu_view_thread_details(post_id)
continue
try:
selected_category = category_details(option)
menu_view_thread_details(post_id)
except (KeyError, ValueError):
# Catch a Post ID that is not in the thread list or is not a number
print(
"{}\n\n".format(style_text("Invalid category ID/entry", False, "bold"))
)
def menu_view_thread_details(post_id):
"""Displays thread details, handles related navigation menu"""
option_text = "Type {} to reply, {} to view in {}, {} to search, {} to post a new link, {} to go back, or {} to quit: ".format(
style_text("r", True, "underline"),
style_text("b", True, "underline"),
config.USER.browser,
style_text("s", True, "underline"),
style_text("p", True, "underline"),
style_text("m", True, "underline"),
style_text("q", True, "underline"),
)
while True:
parent_id, post_url = print_thread_details(post_id)
option = input(option_text).lower()
if option == "m":
return
if option == "b":
view_link_in_browser(post_url)
continue
if option == "r":
reply(parent_id)
continue
if option == "s":
search()
continue
if option == "p":
post_id = post_link()
if post_id >= 0:
continue
break
if option == "q":
graceful_exit()
print("{}\n\n".format(style_text("Invalid entry", False, "bold")))
## GENERAL
@ -680,15 +683,14 @@ def style_text(text: str, is_input: bool, *args) -> str:
def print_banner():
"""prints a banner"""
print(" ----------")
print(" LINKULATOR")
print(" ----------")
return " LINKULATOR ".encode(code)
def graceful_exit():
"""Prints a nice message, performs cleanup and then exits"""
print("\n\nThank you for linkulating. Goodbye.\n")
config.USER.save()
curses.endwin()
print("\n\nThank you for linkulating. Goodbye.\n")
sys.exit(0)
@ -697,17 +699,21 @@ def signal_handler(sig, frame):
graceful_exit()
def main():
def main(stdscr):
"""main function - handles argument parsing and calls menu system"""
signal.signal(signal.SIGINT, signal_handler)
args = sys.argv[1:]
config.init()
if not args:
menu_view_categories()
new_main_menu(stdscr)
elif args[0] in ["-h", "--help", "help"]:
curses.endwin()
print(HELP_TEXT)
sys.exit(0)
else:
curses.endwin()
print("Unknown command: {}".format(args[0]))
sys.exit(0)
graceful_exit()
@ -732,4 +738,4 @@ A few important points about Linkulator:
see your contributions.
* Linkulator may not work outside of Linux systems.
"""
main()
curses.wrapper(main)

View File

@ -37,3 +37,46 @@ class TestPrintSearchResults(unittest.TestCase):
) # one count for title, 4 for the items and a blank line for formatting
self.assertListEqual(test_print_calls, mock_print.call_args_list)
class TestPrintCategories(unittest.TestCase):
def test_general(self):
categories = [
{
"name": "category 1",
"count": 1,
"last_updated": "10",
},
{
"name": "category 2",
"count": 2,
"last_updated": "100",
},
{
"name": "long category name that will be truncated because it's a long line, longer than the terminal width. that's for sure.",
"count": 20,
"last_updated": "1000",
},
]
cols = 80
test_results = [
" ID# New Category",
" 1 * category 1 (1)",
" 2 * category 2 (2)",
" 3 * long category name that will be truncated because it's a long... (20)",
]
test_output = linkulator.print_categories(categories, cols)
self.assertListEqual(test_output, test_results)
def test_empty_categories(self):
empty_categories = []
cols = 80
test_results = [
"",
" There are no posts yet - enter p to post a new link",
]
test_output = linkulator.print_categories(empty_categories, cols)
self.assertListEqual(test_output, test_results)