clinkulator/linkulator.py

964 lines
31 KiB
Python
Executable File

#!/usr/bin/env python3
"""Linkulator"""
# If this script contains bugs, blame cmccabe and asdf.
import curses
import curses.textpad as textpad
import getpass
import locale
import signal
from subprocess import call
import sys
import textwrap
from datetime import datetime
from shutil import which
from time import time
from typing import Tuple
from urllib.parse import urlparse
import config
import data
# linkdata columns:
# id (if parent), username, datestamp, parent-id, category, link-url, link-title
# LinkData = data.LinkData()
# link_data: list = LinkData.link_data
# get locale and set CODE for use in string encoding
locale.setlocale(locale.LC_ALL, "")
CODE = locale.getpreferredencoding()
# VIEWS
def view_categories(categories, cols) -> Tuple[str, list[str]]:
"""Produces categories screen display data. Returns as tuple of header and
body content."""
head = "{:>4s} New {}".format("ID#", "Category")
body: list[str] = []
newmarker = "*"
name_cols = max((cols - 11), 5)
for i, record in enumerate(categories):
index_number = i + 1
name = textwrap.shorten(record["name"], width=name_cols, placeholder="...")
newmarker = "*" if record["last_updated"] >= config.USER.lastlogin else " "
body.append(
"{:4d} {} {} ({})".format(
index_number,
newmarker,
name,
record["count"],
)
)
if not body:
head = ""
body = [
"",
"There are no posts yet - press p to post a new link",
"",
]
return head, body
def view_links(links, category_name, cols) -> Tuple[str, str, list[str]]:
"""Produces links screen display data. Accepts links list, category name and
max column width. Returns a tuple of subtitle, header and body content."""
max_author_cols = max([len(link["link_author"]) for link in links])
author_cols = max(max_author_cols, 6)
description_cols = max((cols - 18 - author_cols - 9 - 1), 5)
# description_cols calulated as: the terminal width, minus the width of ID
# and Date fields and padding, minus the max name length, minus Resp field
# and padding width, minus the unread mark width
subtitle = category_name.title()
head = "{:>3s} {:>10s} {:<{author_cols}s} {:<5} {:<s}".format(
"ID#",
"Date",
"Author",
"#Repl",
"Description",
author_cols=author_cols,
)
body: list[str] = []
for i, link in enumerate(links):
description = textwrap.shorten(
link["description"], width=description_cols, placeholder="..."
)
newmarker = "*" if link["has_new_replies"] else ""
date = datetime.fromtimestamp(float(link["link_timestamp"])).strftime(
"%Y-%m-%d"
)
body.append(
"{:3d} {:>10s} {:<{author_cols}s} [{:3d}] {:s}{}".format(
i + 1,
date,
link["link_author"],
link["reply_count"],
description,
newmarker,
author_cols=author_cols,
)
)
return subtitle, head, body
def view_post(post, cols) -> list[str]:
"""Produces post screen display data. Accepts post id and max column
width. Returns body content list."""
body: list = []
# set up line wrapping
line_wrapper = textwrap.TextWrapper(
initial_indent=" " * 2, subsequent_indent=" " * 21, width=cols
)
# post detail view
body.append(" {:<12}: {}".format("Title", post["title"]))
body.append(" {:<12}: {}".format("Link", post["url"]))
body.append(" {:<12}: {}".format("Category", post["category"]))
body.append(" {:<12}: {}".format("User", post["author"]))
date = datetime.fromtimestamp(float(post["timestamp"])).strftime("%c")
body.append(" {:<12}: {}".format("Date", date))
# post reply view
if post["replies"]:
body.append("\n {}:\n".format("Replies"))
for replies in post["replies"]:
reply_author = replies[1]
reply_date = datetime.fromtimestamp(float(replies[2])).isoformat(
sep=" ", timespec="minutes"
)
reply_text = replies[6]
wrapped_reply = line_wrapper.fill(
" {} {}: {}".format(reply_date, reply_author, reply_text)
).splitlines()
for line in wrapped_reply:
body.append(line)
else:
body.append("\n No replies yet. Be the first!")
return body
def view_search_results(keyword: str, search_results: list):
"""Produces search results display data. Accepts search keyword, search
results and max column width. Returns list of strings."""
# TODO: update to produce list of strings
print(
"\nShowing results for {}\n\n{:>4s} {:<15s}{:<12s}{:<13s}".format(
keyword, "ID#", "DATE", "AUTHOR", "DESC"
)
)
for display_index, record in enumerate(search_results, start=1):
date = datetime.fromtimestamp(float(record[2])).strftime("%Y-%m-%d")
author = record[1]
desc = record[6]
print("{:4d} {:<15s}{:<12s}{:<13s}".format(display_index, date, author, desc))
print("")
# CONTROLS
def search():
"""Control for the search function"""
while True:
print("")
keyword = input("Enter your search (or leave empty to cancel): ")
if keyword == "":
print("Search cancelled\n")
return
search_results = LinkData.search(keyword)
if not search_results:
print("No results found\n")
return
while True:
view_search_results(keyword, search_results)
option = input(
"Enter a post ID to see its thread, {} to start a new search, {} to go back, or {} to quit: ".format(
style_text("s", False, "underline"),
style_text("m", False, "underline"),
style_text("q", False, "underline"),
)
).lower()
if option == "q":
graceful_exit()
if option == "m":
return
if option == "s":
break
try:
if 1 <= int(option) <= len(search_results):
menu_view_thread_details(search_results[int(option) - 1][0])
else:
raise IndexError("Invalid post ID")
except (KeyError, ValueError, IndexError):
# Catch a Post ID that is not in the thread list or is not a number
print("{}".format(style_text("Invalid entry", False, "bold")))
def open_link_in_browser(url):
"""Attempts to view the specified URL in the configured browser"""
if which(config.USER.browser) is None:
print(
"Sorry, "
+ config.USER.browser
+ " is not installed on your system. Ask your sysadmin to install it."
)
return
url_scheme = urlparse(url).scheme
if url_scheme in ["gopher", "https", "http"]:
call([config.USER.browser, url])
else:
print("Sorry, that url doesn't start with gopher://, http:// or https://")
try_anyway = input(
"Do you want to try it in {} anyway? Y/[N]".format(config.USER.browser)
).lower()
if try_anyway == "y":
call([config.USER.browser, url])
def command_bar():
"""Uses defined stdscr to display a command input prompt. returns command text"""
# draw the prompt in its own window
prompt_win = curses.newwin(1, curses.COLS, curses.LINES - 1, 0)
prompt_win.addch(0, 0, ":")
prompt_win.clrtoeol()
prompt_win.refresh()
# draw the command field and return gathered text
command_win = curses.newwin(1, curses.COLS, curses.LINES - 1, 1)
command_field = textpad.Textbox(command_win)
command_field.stripspaces = True
curses.curs_set(1)
command_field.edit()
command_text = command_field.gather()
command_text = command_text.strip()
curses.curs_set(0)
return command_text
def get_reply():
# draw the prompt in its own window
reply_prompt = curses.newwin(1, curses.COLS, curses.LINES - 5, 0)
reply_prompt.addnstr(
0,
0,
"Enter your comment (or leave empty to cancel). Press enter to submit.",
curses.COLS,
)
reply_prompt.clrtoeol()
reply_prompt.refresh()
# loop until valid input can be gathered
while True:
reply_window = curses.newwin(4, curses.COLS, curses.LINES - 4, 0)
reply_field = textpad.Textbox(reply_window)
reply_field.stripspaces = True
curses.curs_set(1)
reply_field.edit(complete_on_enter)
reply_text = reply_field.gather()
reply_text = reply_text.strip()
curses.curs_set(0)
if is_valid_input(reply_text):
break
else:
reply_prompt.addnstr(
0, 0, "Sorry, replies can't contain pipes '|'", curses.COLS
)
reply_prompt.clrtoeol()
reply_prompt.refresh()
return reply_text
def complete_on_enter(char):
# https://stackoverflow.com/questions/36121802/python-curses-make-enter-key-terminate-textbox
# if enter, return CTRL+G equivalent
if char == 10:
char = 7
return char
def is_valid_input(entry: str) -> bool:
"""Determine validity of an input string
>>> is_valid_input("valid")
True
>>> is_valid_input("Not|valid")
False
>>> is_valid_input(" ")
False
"""
if "|" in entry:
return False
# if entry.strip() == "":
# return False
return True
def get_new_post(categories):
"""input form for new post. returns new post data or None"""
# draw the prompt in its own window
new_post_title = curses.newwin(8, curses.COLS, 4, 0)
new_post_title.addnstr(
0, 0, "Enter the requested details, to cancel submit a blank field", curses.COLS
)
new_post_title.clrtoeol()
new_post_title.addnstr(1, 0, "URL:", curses.COLS)
new_post_title.clrtoeol()
new_post_title.addnstr(2, 0, "Title:", curses.COLS)
new_post_title.clrtoeol()
new_post_title.addnstr(3, 0, "Category:", curses.COLS)
new_post_title.clrtobot()
new_post_title.refresh()
# loop until valid input can be gathered
post_data = {}
items = ["url", "title", "category"]
for i, item in enumerate(items):
startrow = i + 5
while True:
item_window = curses.newwin(1, curses.COLS, startrow, 10)
item_field = textpad.Textbox(item_window)
item_field.stripspaces = True
curses.curs_set(1)
item_field.edit()
item_text = item_field.gather()
item_text = item_text.strip()
curses.curs_set(0)
if not item_text:
return None
if item == "category":
# TODO: some sort of category picker, who knows
pass
if is_valid_input(item_text):
break
else:
new_post_title.addnstr(
0, 0, "Sorry, {} can't contain pipes '|'".format(item), curses.COLS
)
new_post_title.clrtoeol()
new_post_title.refresh()
post_data[item] = item_text
return post_data
class Content:
"""Menu content that is output to the curses screen, plus a method to
calculate how it is displayed"""
def __init__(self):
self.subtitle = None
self.head = None
self.body = None
self.length = None
self.miny = None
self.maxy = None
self.scrollminy = 0
self.viewslice = None
def reset_dimensions(self):
self.scrollminy = 0
def calc_dimensions(self):
if not self.body:
raise ValueError("Cannot calculate - no content set")
self.length = len(self.body)
if self.head and self.subtitle:
self.miny = 3
else:
self.miny = 2
self.maxy = curses.LINES - 3
self.viewslice = slice(self.scrollminy, self.scrollminy + self.maxy)
def scroll_down(self):
if self.scrollminy <= (self.length - self.maxy):
self.scrollminy += 1
def scroll_up(self):
if self.scrollminy > 0:
self.scrollminy -= 1
class Menu:
"""Class describing the data contained in each menu level. A level is like a
menu screen in a hierarchy."""
def __init__(self, name):
self.name = name
self.data = None
self.content = Content()
self.selected_key = None
class MenuHierarchy:
"""Class containing all menus, describing their hierarchy and methods to
handle movement between levels"""
def __init__(self):
# primary menu hierarchy: categories -> links -> post
categories = Menu("categories")
links = Menu("links")
post = Menu("post")
self.primary_hierarchy = [categories, links, post]
# search menu hierarchy: search_results -> found_post
search_results = Menu("search_results")
found_post = Menu("found_post")
self.search_hierarchy = [search_results, found_post]
# help page - single page
self.user_help_page = Menu("user_help_page")
# hierarchy settings
self.is_viewing_primary_hierarchy = True
self.primary_index = 0
self.search_index = 0
self.active = self.primary_hierarchy[self.primary_index]
def back(self):
"""goes back up the menu hierarchy"""
if self.is_viewing_primary_hierarchy:
if self.primary_index > 0:
self.primary_index -= 1
self.active = self.primary_hierarchy[self.primary_index]
else:
if self.search_index > 0:
self.search_index -= 1
self.active = self.search_hierarchy[self.search_index]
else:
self.active = self.primary_hierarchy[self.primary_index]
self.is_viewing_primary_hierarchy = True
def forward(self):
"""returns forward through previously visited menu hierarchy"""
if self.is_viewing_primary_hierarchy:
if self.primary_index < len(self.primary_hierarchy) - 1:
if self.primary_hierarchy[self.primary_index + 1].data:
self.primary_index += 1
self.active = self.primary_hierarchy[self.primary_index]
else:
if self.search_index < len(self.search_hierarchy) - 1:
if self.search_hierarchy[self.search_index + 1].data:
self.search_index += 1
self.active = self.search_hierarchy[self.search_index]
def get_active_menu_data(self, LinkData):
"""get active menu data based on menu hierarchy settings"""
if self.active.name == "categories":
self.active.data = LinkData.categories
(
self.active.content.head,
self.active.content.body,
) = view_categories(self.active.data, curses.COLS)
self.active.content.subtitle = "All Categories"
elif self.active.name == "links":
# reinitialise lower-level menu
self.primary_hierarchy[self.primary_index + 1].data = None
# set data
self.active.data = LinkData.get_links_by_category_name(
self.active.selected_key
)
(
self.active.content.subtitle,
self.active.content.head,
self.active.content.body,
) = view_links(self.active.data, self.active.selected_key, curses.COLS)
elif self.active.name == "post":
self.active.data = LinkData.get_post(self.active.selected_key)
self.active.content.subtitle = "Viewing post:"
self.active.content.body = view_post(self.active.data, curses.COLS)
elif self.active.name == "search_results":
self.is_viewing_primary_hierarchy = False
pass
elif self.active.name == "search_results_thread_details":
self.active.data = LinkData.get_post(self.active.selected_key)
self.active.content.body = view_post(self.active.data, curses.COLS)
elif self.active.name == "user_help_page":
self.is_viewing_primary_hierarchy = False
self.active.content.subtitle = "Quick Reference Guide"
self.active.content.body = user_help_text(curses.COLS)
else:
raise ValueError("This shouldn't happen?")
def navigate(self, int_action, linkdata, screen):
"""Navigate to an entry by index"""
if int_action:
if self.active.name == "categories":
try:
key = self.active.data[int_action - 1]["name"]
except IndexError:
screen.status = "Sorry, that category doesn't exist"
return
elif self.active.name == "links":
try:
key = self.active.data[int_action - 1]["post_id"]
except IndexError:
screen.status = "Sorry, that link doesn't exist"
return
elif self.active.name == "search_results":
# TODO
pass
else:
# Not a navigable screen
return
self.primary_index += 1
self.active = self.primary_hierarchy[self.primary_index]
self.active.selected_key = key
self.get_active_menu_data(linkdata)
self.active.content.reset_dimensions()
self.active.content.calc_dimensions()
def go_to_post(self, post_category, post_id, linkdata):
"""Navigates directly to the specified post while also reloading
category and links pages"""
# set categories screen
self.primary_index = 0
self.active = self.primary_hierarchy[self.primary_index]
self.get_active_menu_data(linkdata)
self.active.content.calc_dimensions()
# set links screen
self.primary_index = 1
self.active = self.primary_hierarchy[self.primary_index]
self.active.selected_key = post_category
self.get_active_menu_data(linkdata)
self.active.content.calc_dimensions()
# set post screen
self.primary_index = 2
self.active = self.primary_hierarchy[self.primary_index]
self.active.selected_key = post_id
self.get_active_menu_data(linkdata)
self.active.content.calc_dimensions()
def reload_current(self, linkdata):
"""Reloads data for primary heirarchy"""
for i in range(len(self.primary_hierarchy)):
self.primary_index = i
self.active = self.primary_hierarchy[self.primary_index]
self.get_active_menu_data(linkdata)
self.active.content.calc_dimensions()
class Screen:
def __init__(self):
self.title: str = "Linkulator"
self.content = None
self.status: str = "Welcome! Press ? for keys and commands"
def set_content(self, content):
self.content = content
def print(self, stdscr):
# print title
stdscr.addnstr(0, 0, self.title, curses.COLS)
stdscr.clrtoeol()
# print subtitle
if self.content.subtitle:
stdscr.addnstr(1, 0, self.content.subtitle.encode(CODE), curses.COLS)
stdscr.clrtoeol()
# print content header
if self.content.head:
stdscr.addnstr(
self.content.miny - 1,
0,
self.content.head.encode(CODE),
curses.COLS,
)
stdscr.clrtoeol()
# print body
self.content.calc_dimensions()
count = 0
for i, line in enumerate(self.content.body[self.content.viewslice]):
# don't print beyond the screen
if i >= curses.LINES:
break
count += count
stdscr.addnstr(i + self.content.miny, 0, line.encode(CODE), curses.COLS)
stdscr.clrtoeol()
# this is meant to clear to the second last line, but...
# just erase to the bottom for now
stdscr.clrtobot()
# print status (footer)
if self.status:
stdscr.addnstr((curses.LINES - 1), 0, self.status.encode(CODE), curses.COLS)
stdscr.clrtoeol()
else:
stdscr.move(curses.LINES - 1, 0)
stdscr.clrtoeol()
# clear message for next loop
self.status = ""
def term_resize(stdscr):
"""resizes the provided stdscr to the current size"""
# TODO: ensure resize can decrement bodyscrollmin by the appropriate amount
# if there is free space available
stdscr.clear()
curses.resizeterm(*stdscr.getmaxyx())
curses.flushinp()
curses.update_lines_cols()
def control_loop(stdscr):
"""main loop of program, prints data and takes action based on user input"""
curses.use_default_colors()
curses.curs_set(0)
curses.set_escdelay(25)
linkdata = data.LinkData()
menus = MenuHierarchy()
menus.get_active_menu_data(linkdata)
menus.active.content.calc_dimensions()
screen = Screen()
while True:
curses.update_lines_cols()
screen.set_content(menus.active.content)
screen.print(stdscr)
stdscr.refresh()
try:
k = stdscr.getkey()
except (curses.error):
screen.status = "Error {} on action {}".format(str(curses.error), str(k))
k = None
continue
if k == "KEY_RESIZE":
term_resize(stdscr)
elif k in [":", " "]:
action = command_bar()
handle_command(action, screen, menus, linkdata)
else:
stdscr.erase()
stdscr.refresh()
handle_hotkey(k, screen, menus, linkdata)
def handle_command(action, screen, menus, linkdata):
int_action = None
try:
int_action = int(action)
except ValueError:
# it's not a number but that's ok
pass
if int_action:
menus.navigate(int_action, linkdata, screen)
else:
do_command(action, screen, menus, linkdata)
def do_command(action, screen, menus, linkdata):
"""Executes the specified action, updating screen status if required"""
if action in ["?", "help"]:
menus.active = menus.user_help_page
menus.get_active_menu_data(None)
menus.active.content.reset_dimensions()
menus.active.content.calc_dimensions()
elif action in ["q", "quit", "exit", "\x04"]:
graceful_exit()
elif action in ["c", "create"]:
new_post_data = get_new_post(linkdata.categories)
if new_post_data:
new_post_record = data.LinkDataRecord(
username=getpass.getuser(),
timestamp=str(time()),
category=new_post_data["category"],
link_URL=new_post_data["url"],
link_title_or_comment=new_post_data["title"],
)
new_post_id = linkdata.add(new_post_record)
if new_post_id:
menus.go_to_post(new_post_data["category"], new_post_id, linkdata)
else:
screen.status = "Error: failed to create post" # this is unlikely
else:
screen.status = "Create post cancelled"
elif action in ["s", "search"]:
screen.status = "Search not yet implemented"
# search()
# TODO: open search screen
# level.prior_to_search = level.current
# results are returned to a search list, displayed by setting menu level to search
elif action in ["r", "reply"]:
if menus.active.name in [
"search_result_thread_details",
"post",
]:
reply_text = get_reply()
if reply_text:
reply_record = data.LinkDataRecord(
username=getpass.getuser(),
timestamp=str(time()),
parent_id=menus.active.data["parent_id"],
link_title_or_comment=reply_text,
)
_ = linkdata.add(reply_record)
menus.reload_current(linkdata)
else:
screen.status = "Reply cancelled"
else:
screen.status = "View a post to make a reply"
elif action in ["o", "open"] and menus.active.name in [
"search_result_thread_details",
"thread_details",
]:
screen.status = "Open in browser not yet implemented"
# open_link_in_browser(thread_details["url"])
# open link in external program
# is o the right command?
elif action == "":
return
else:
screen.status = "Invalid command. Press ? for help"
def handle_hotkey(action, screen, menus, linkdata):
int_action = None
try:
int_action = int(action)
except ValueError:
# it's not a number but that's ok
pass
if int_action:
menus.navigate(int_action, linkdata, screen)
else:
do_hotkey(action, screen, menus, linkdata)
def do_hotkey(action, screen, menus, linkdata):
"""Executes the provided hotkey action"""
if action in ["j", "KEY_DOWN"]:
screen.content.scroll_down()
elif action in ["k", "KEY_UP"]:
screen.content.scroll_up()
elif action in ["b", "KEY_LEFT"]:
menus.back()
elif action in ["f", "KEY_RIGHT"]:
menus.forward()
elif action == "?":
menus.active = menus.user_help_page
menus.get_active_menu_data(None)
menus.active.content.reset_dimensions()
menus.active.content.calc_dimensions()
elif action in ["q", "\x04"]:
# \x04 is CTRL+D
graceful_exit()
elif action == "c":
new_post_data = get_new_post(linkdata.categories)
if new_post_data:
new_post_record = data.LinkDataRecord(
username=getpass.getuser(),
timestamp=str(time()),
category=new_post_data["category"],
link_URL=new_post_data["url"],
link_title_or_comment=new_post_data["title"],
)
new_post_id = linkdata.add(new_post_record)
if new_post_id:
menus.go_to_post(new_post_data["category"], new_post_id, linkdata)
else:
screen.status = "Error: failed to create post" # this is unlikely
else:
screen.status = "Create post cancelled"
elif action == "/":
screen.status = "Search not yet implemented"
# search()
# TODO: open search screen
# level.prior_to_search = level.current
# results are returned to a search list, displayed by setting menu level to search
elif action == "r":
if menus.active.name in [
"search_result_thread_details",
"post",
]:
reply_text = get_reply()
if reply_text:
reply_record = data.LinkDataRecord(
username=getpass.getuser(),
timestamp=str(time()),
parent_id=menus.active.data["parent_id"],
link_title_or_comment=reply_text,
)
_ = linkdata.add(reply_record)
menus.reload_current(linkdata)
else:
screen.status = "Reply cancelled"
else:
screen.status = "View a post to make a reply"
elif action == "o" and menus.active.name in [
"search_result_thread_details",
"thread_details",
]:
screen.status = "Open in browser not yet implemented"
# open_link_in_browser(thread_details["url"])
# open link in external program
# is o the right command?
# GENERAL
def style_text(text: str, is_input: bool, *args) -> str:
"""Style input strings as specified using terminal escape sequences. Returns a styled string"""
# TODO: not sure if this function will work with curses
styles = {
"bold": "\033[1m",
"dim": "\033[2m",
"underline": "\033[4m",
"blink": "\033[5m", # This is here if you REALLY need it...dont use it.
# (ctrl+shift+esc+E to enable evil mode.)
"inverse": "\033[7m", # Make fg and bg color swap
"rl_left_fix": "\001" if is_input else "",
"rl_right_fix": "\002" if is_input else "",
}
out = ""
for arg in args:
if arg in styles:
out += styles["rl_left_fix"]
out += styles[arg]
out += styles["rl_right_fix"]
out += text
out += "{}\033[0m{}".format(styles["rl_left_fix"], styles["rl_right_fix"])
return out
def graceful_exit():
"""Prints a nice message, performs cleanup and then exits"""
config.USER.save()
curses.endwin()
print("\n\nThank you for linkulating. Goodbye.\n")
sys.exit(0)
def signal_handler(sig, frame):
"""handle signals, exiting on SIGINT"""
graceful_exit()
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:
control_loop(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()
def help_text():
help_text = """
options: -h or --help; or no option to browse links.
Linkulator is a minimalist, commandline link aggregator for small, trusting shell communities.
A few important points about Linkulator:
* Your username is associated with everything you post. No real anonymity.
* You may ignore other users by adding their username to your ~/.linkulator/ignore file,
followed by an optional description of why you're ignoring them.
* Link post categories are created dynamically when you submit a link. Think before you
create a new category and don't litter the board.
* No files are stored centrally. Each users' contributions are stored in ~/.linkulator/,
meaning that you may always edit or delete your own files. Please don't use this ability
to deceive others.
* Link/reply threads disappear if the original link poster deletes their post; or if you
put their username in your ignore file.
* Your ~/.linkulator/linkulator.data file must be readable by others, or nobody else will
see your contributions.
* Linkulator may not work outside of Linux systems.
"""
return help_text
def user_help_text(cols):
user_help_text = """- press q to quit -
- press space or : to enter a command -
- scroll the page with j/k or up and down arrows -
-keys-
Number keys - navigate to the numbered item
space or : - open the command prompt
j or down arrow - scroll down the page
k or up arrow - scroll up the page
b or left arrow - go back to the previous page
f or right arrow - go forward to a previously visited page
c - create a new post
r - reply to a post
o - open a link
/ - search all links using a keyword
q - quit the application
-commands-
q, quit, exit - quit the application
r, reply - reply to the post on screen
o, open - open the link from the post on screen in your browser
s, search - search all links using a keyword
b, back - go back to the previous page
f, forward - go forward to a previously visited page
c, create - create a new post
?, help - show this help page
"""
return user_help_text.splitlines()
if __name__ == "__main__":
curses.wrapper(main)