forked from cmccabe/linkulator2
Basic curses functionality - show categories and scroll list, handle
term resizing
This commit is contained in:
parent
e66d793646
commit
8fe9af529b
226
linkulator.py
226
linkulator.py
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue