diff --git a/linkulator.py b/linkulator.py index 102f4e1..46b7ecc 100755 --- a/linkulator.py +++ b/linkulator.py @@ -23,8 +23,12 @@ 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 +# 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 @@ -32,11 +36,11 @@ link_data: list = LinkData.link_data def view_categories(categories, cols) -> Tuple[str, list[str]]: """Produces categories screen display data. Returns as tuple of header and - content.""" + body content.""" - thead = "{:>4s} New {}".format("ID#", "Category") + head = "{:>4s} New {}".format("ID#", "Category") - content: list[str] = [] + body: list[str] = [] newmarker = "*" name_cols = max((cols - 11), 5) @@ -44,7 +48,7 @@ def view_categories(categories, cols) -> Tuple[str, list[str]]: index_number = i + 1 name = textwrap.shorten(record["name"], width=name_cols, placeholder="...") newmarker = "*" if record["last_updated"] >= config.USER.lastlogin else " " - content.append( + body.append( "{:4d} {} {} ({})".format( index_number, newmarker, @@ -53,15 +57,19 @@ def view_categories(categories, cols) -> Tuple[str, list[str]]: ) ) - if not content: - thead = "" - content = "\nThere are no posts yet - enter p to post a new link\n".splitlines() - return thead, content + 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, list[str]]: +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 header and content.""" + 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) @@ -71,8 +79,9 @@ def view_links(links, category_name, cols) -> Tuple[str, list[str]]: # and Date fields and padding, minus the max name length, minus Resp field # and padding width, minus the unread mark width - thead = " {}\n {:>3s} {:>10s} {:<{author_cols}s} {:<5} {: Tuple[str, list[str]]: author_cols=author_cols, ) - content: list[str] = [] + body: list[str] = [] for i, link in enumerate(links): description = textwrap.shorten( @@ -92,8 +101,8 @@ def view_links(links, category_name, cols) -> Tuple[str, list[str]]: "%Y-%m-%d" ) - content.append( - " {:3d} {:>10s} {:<{author_cols}s} [{:3d}] {:s}{}".format( + body.append( + "{:3d} {:>10s} {:<{author_cols}s} [{:3d}] {:s}{}".format( i + 1, date, link["link_author"], @@ -104,14 +113,14 @@ def view_links(links, category_name, cols) -> Tuple[str, list[str]]: ) ) - return thead, content + return subtitle, head, body def view_post(post, cols) -> list[str]: """Produces post screen display data. Accepts post id and max column - width. Returns content list.""" + width. Returns body content list.""" - output: list = [] + body: list = [] # set up line wrapping line_wrapper = textwrap.TextWrapper( @@ -119,30 +128,30 @@ def view_post(post, cols) -> list[str]: ) # post detail view - output.append(" {:<12}: {}".format("Title", post["title"])) - output.append(" {:<12}: {}".format("Link", post["url"])) - output.append(" {:<12}: {}".format("Category", post["category"])) - output.append(" {:<12}: {}".format("User", post["author"])) + 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") - output.append(" {:<12}: {}".format("Date", date)) + body.append(" {:<12}: {}".format("Date", date)) # post reply view if post["replies"]: - output.append("\n {}:\n".format("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] - output.append( + body.append( line_wrapper.fill( " {} {}: {}".format(reply_date, reply_author, reply_text) ) ) else: - output.append("\n No replies yet. Be the first!") - return output + body.append("\n No replies yet. Be the first!") + return body def view_search_results(keyword: str, search_results: list): @@ -246,7 +255,7 @@ def command_bar(): return command_text -def new_reply(): +def get_reply(): # draw the prompt in its own window reply_prompt = curses.newwin(1, curses.COLS, curses.LINES - 5, 0) reply_prompt.addnstr( @@ -282,7 +291,7 @@ def new_reply(): def complete_on_enter(char): # https://stackoverflow.com/questions/36121802/python-curses-make-enter-key-terminate-textbox - # # if enter, return CTRL+G equivalent + # if enter, return CTRL+G equivalent if char == 10: char = 7 return char @@ -305,7 +314,8 @@ def is_valid_input(entry: str) -> bool: return True -def new_post(): +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( @@ -339,6 +349,9 @@ def new_post(): 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: @@ -352,99 +365,42 @@ def new_post(): return post_data -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 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" - return True - - -def get_input(item: str) -> str: - """Get user input with the specified prompt, validate and return it, or - break if invalid""" - while True: - i: str = input("{}: ".format(style_text(item, True, "underline"))) - if i == "": - raise ValueError("Empty field") - if i == "?": - category_text = ( - "No categories yet, feel free to make a new one!" - if not LinkData.categories - else ", ".join(sorted(record["name"] for record in LinkData.categories)) - ) - print( - "Available categories: {}\n" - "♻️ Please help keep Linkulator tidy".format(category_text) - ) - continue - if is_valid_input(i): - return i - print( - "Entries consisting of whitespace, or containing pipes, '|', are " - "not valid.Please try again." - ) - - -def post_link() -> int: - """Handles the link posting process""" - print("\nPost a link by entering the details below.") - print( - "Enter {} for a list of categories. Enter an empty field to cancel.\n".format( - style_text("?", False, "underline") - ) - ) - - try: - url = get_input("URL") - category = get_input("Category") - while not is_correct_category(category): - category = get_input("Category") - title = get_input("Title") - except ValueError: - print("Post cancelled") - return -1 - - record = data.LinkDataRecord( - username=getpass.getuser(), - timestamp=str(time()), - category=category, - link_URL=url, - link_title_or_comment=title, - ) - - return LinkData.add(record) - - -class Output: +class Content: """Menu content that is output to the curses screen, plus a method to calculate how it is displayed""" def __init__(self): - self.thead = None - self.content = None + 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(self): + def reset_dimensions(self): self.scrollminy = 0 def calc_dimensions(self): - if not self.content: - raise ValueError("Can't calculate nonexistant data") - self.length = len(self.content) - if self.thead: + 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 @@ -453,7 +409,7 @@ class Menu: def __init__(self, name): self.name = name self.data = None - self.output = Output() + self.content = Content() self.selected_key = None @@ -473,11 +429,11 @@ class MenuHierarchy: found_post = Menu("found_post") self.search_hierarchy = [search_results, found_post] - # help page + # 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] @@ -509,14 +465,15 @@ class MenuHierarchy: self.search_index += 1 self.active = self.search_hierarchy[self.search_index] - def set_active_menu_data(self, LinkData): + 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.output.thead, - self.active.output.content, + 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 @@ -526,31 +483,143 @@ class MenuHierarchy: self.active.selected_key ) ( - self.active.output.thead, - self.active.output.content, + 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.output.content = view_post(self.active.data, curses.COLS) + 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.output.content = view_post(self.active.data, curses.COLS) + 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.output.content = user_help_text(curses.COLS) + 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() -class Status: + 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.message: str - self.action: str + 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): @@ -563,85 +632,31 @@ def term_resize(stdscr): curses.update_lines_cols() -def menu_system(stdscr): +def control_loop(stdscr): """main loop of program, prints data and takes action based on user input""" - locale.setlocale(locale.LC_ALL, "") - code = locale.getpreferredencoding() curses.use_default_colors() curses.curs_set(0) + curses.set_escdelay(25) - title = "Linkulator".encode(code) + linkdata = data.LinkData() menus = MenuHierarchy() - menus.set_active_menu_data(LinkData) - menus.active.output.calc_dimensions() + menus.get_active_menu_data(linkdata) + menus.active.content.calc_dimensions() - status = Status() - status.message = "" - status.action = "" + screen = Screen() while True: - curses.set_escdelay(25) curses.update_lines_cols() - # TODO: consider stdscr.erase() - # print title - stdscr.addnstr(0, 1, title, curses.COLS) - stdscr.clrtoeol() - - # TODO: print subtitle? - # stdscr.addnstr(1, 4, "All categories".encode(code), curses.COLS) - - # print header - if menus.active.output.thead: - stdscr.addnstr( - menus.active.output.miny - 1, - 0, - menus.active.output.thead.encode(code), - curses.COLS, - ) - stdscr.clrtoeol() - - # print body - menus.active.output.calc_dimensions() - - count = 0 - for i, line in enumerate( - menus.active.output.content[menus.active.output.viewslice] - ): - # guard just in case we try to print beyond the window because of - # some resize stuff - if i >= curses.LINES: - break - count += count - stdscr.addnstr( - i + menus.active.output.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 - if status.message: - status_text = "{} - {}".format(status.message, status.action) - stdscr.addnstr((curses.LINES - 1), 0, status_text.encode(code), curses.COLS) - stdscr.clrtoeol() - else: - stdscr.move(curses.LINES - 1, 0) - stdscr.clrtoeol() - # clear message for next loop - status.message = "" - - # refresh screen output with all changes made + screen.set_content(menus.active.content) + screen.print(stdscr) stdscr.refresh() try: k = stdscr.getkey() except (curses.error): - status.message = str(curses.error) - status.action = str(k) + screen.status = "Error {} on action {}".format(str(curses.error), str(k)) k = None continue @@ -649,34 +664,14 @@ def menu_system(stdscr): term_resize(stdscr) elif k in [":", " "]: action = command_bar() - handle_action(menus, action, status) + handle_command(action, screen, menus, linkdata) else: stdscr.erase() stdscr.refresh() - handle_action(menus, k, status) + handle_hotkey(k, screen, menus, linkdata) -# menu loop -# action = get action from input -# return valid action or error message -# actions: -# navigate menu -# get data for menu change -# back/forward -# screen change -# set screen data -# scroll up/down -# get new post or reply input -# send data for input -# search -# perform search -# return search results -# open external program -# shutdown curses, start program, restart curses on return -# show help screen - - -def handle_action(menus, action, status): +def handle_command(action, screen, menus, linkdata): int_action = None try: int_action = int(action) @@ -685,63 +680,22 @@ def handle_action(menus, action, status): pass if int_action: - navigate(menus, int_action) + menus.navigate(int_action, linkdata, screen) else: - do_command(menus, action) + do_command(action, screen, menus, linkdata) -def navigate(menus, int_action): - if int_action: - if menus.active.name == "categories": - try: - key = menus.active.data[int_action - 1]["name"] - except IndexError: - raise IndexError("Sorry, that category doesn't exist") - return - elif menus.active.name == "links": - try: - key = menus.active.data[int_action - 1]["post_id"] - except IndexError: - raise IndexError("Sorry, that link doesn't exist") - return - elif menus.active.name == "search_results": - # TODO - pass - else: - # no action because it's not a valid menu - return - menus.primary_index += 1 - menus.active = menus.primary_hierarchy[menus.primary_index] - menus.active.selected_key = key - menus.set_active_menu_data(LinkData) - menus.active.output.reset() - menus.active.output.calc_dimensions() - - -def do_command(menus, action): - """???""" - if action in ["j", "KEY_DOWN"]: - # TODO: put up/down controls in output class? - if menus.active.output.scrollminy <= ( - menus.active.output.length - menus.active.output.maxy - ): - menus.active.output.scrollminy += 1 - elif action in ["k", "KEY_UP"]: - if menus.active.output.scrollminy > 0: - menus.active.output.scrollminy -= 1 - elif action in ["b", "back", "KEY_LEFT"]: - menus.back() - elif action in ["f", "forward", "KEY_RIGHT"]: - menus.forward() - elif action in ["?", "help"]: +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.set_active_menu_data(None) - menus.active.output.reset() - menus.active.output.calc_dimensions() + 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 = new_post() + new_post_data = get_new_post(linkdata.categories) if new_post_data: new_post_record = data.LinkDataRecord( username=getpass.getuser(), @@ -751,57 +705,142 @@ def do_command(menus, action): link_title_or_comment=new_post_data["title"], ) - new_post_id = LinkData.add(new_post_record) + new_post_id = linkdata.add(new_post_record) if new_post_id: - # set categories screen - menus.primary_index = 0 - menus.active = menus.primary_hierarchy[menus.primary_index] - menus.set_active_menu_data(LinkData) - menus.active.output.calc_dimensions() - - # set links screen - menus.primary_index = 1 - menus.active = menus.primary_hierarchy[menus.primary_index] - menus.active.selected_key = new_post_data["category"] - menus.set_active_menu_data(LinkData) - menus.active.output.calc_dimensions() - - # set post screen - menus.primary_index = 2 - menus.active = menus.primary_hierarchy[menus.primary_index] - menus.active.selected_key = new_post_id - menus.set_active_menu_data(LinkData) - menus.active.output.calc_dimensions() + 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"]: - pass + 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"] and menus.active.name in [ - "search_result_thread_details", - "post", - ]: - reply_text = new_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.set_active_menu_data(LinkData) - menus.active.output.calc_dimensions() + 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? @@ -853,7 +892,7 @@ def main(stdscr): args = sys.argv[1:] config.init() if not args: - menu_system(stdscr) + control_loop(stdscr) elif args[0] in ["-h", "--help", "help"]: curses.endwin() print(help_text()) @@ -890,31 +929,32 @@ A few important points about Linkulator: def user_help_text(cols): - user_help_text = """Welcome to Linkulator! -Press Q to quit -Press space or : to enter a command + 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 -j/k or up/down arrow to scroll a page -b and f to go back and forward -c to create a new post -r to reply to a post -o to open a link +-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: exit the application - - - -long page -long page - - - - - - -here's a really really long line as the final paragraph that is likely to exceed the terminal width +-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()