def test_layout_class(): c1 = BufferControl() c2 = BufferControl() c3 = BufferControl() win1 = Window(content=c1) win2 = Window(content=c2) win3 = Window(content=c3) layout = Layout(container=VSplit([ HSplit([ win1, win2 ]), win3 ])) # Listing of windows/controls. assert list(layout.find_all_windows()) == [win1, win2, win3] assert list(layout.find_all_controls()) == [c1, c2, c3] # Focusing something. layout.focus(c1) assert layout.has_focus(c1) assert layout.has_focus(win1) assert layout.current_control == c1 assert layout.previous_control == c1 layout.focus(c2) assert layout.has_focus(c2) assert layout.has_focus(win2) assert layout.current_control == c2 assert layout.previous_control == c1 layout.focus(win3) assert layout.has_focus(c3) assert layout.has_focus(win3) assert layout.current_control == c3 assert layout.previous_control == c2 # Pop focus. This should focus the previous control again. layout.focus_last() assert layout.has_focus(c2) assert layout.has_focus(win2) assert layout.current_control == c2 assert layout.previous_control == c1
def test_layout_class(): c1 = BufferControl() c2 = BufferControl() c3 = BufferControl() win1 = Window(content=c1) win2 = Window(content=c2) win3 = Window(content=c3) layout = Layout(container=VSplit([HSplit([win1, win2]), win3])) # Listing of windows/controls. assert list(layout.find_all_windows()) == [win1, win2, win3] assert list(layout.find_all_controls()) == [c1, c2, c3] # Focusing something. layout.focus(c1) assert layout.has_focus(c1) assert layout.has_focus(win1) assert layout.current_control == c1 assert layout.previous_control == c1 layout.focus(c2) assert layout.has_focus(c2) assert layout.has_focus(win2) assert layout.current_control == c2 assert layout.previous_control == c1 layout.focus(win3) assert layout.has_focus(c3) assert layout.has_focus(win3) assert layout.current_control == c3 assert layout.previous_control == c2 # Pop focus. This should focus the previous control again. layout.focus_last() assert layout.has_focus(c2) assert layout.has_focus(win2) assert layout.current_control == c2 assert layout.previous_control == c1
class TUI(object): key_bindings = KeyBindings() def __init__(self): self.console = TextArea( scrollbar=True, focusable=False, line_numbers=False, lexer=PygmentsLexer(SerTermLexer), ) self.cmd_line = TextArea( multiline=False, prompt=HTML('<orange>>>> </orange>'), style='bg: cyan', accept_handler=self.cmd_line_accept_handler, history=FileHistory('.ser-term-hist'), auto_suggest=AutoSuggestFromHistory(), ) self.root = HSplit([ self.console, self.cmd_line, ]) self.menu = MenuContainer( self.root, menu_items=[ MenuItem(text='[F2] Open port', handler=self.key_uart_open), MenuItem(text='[F3] Close port', handler=self.key_uart_close), MenuItem( text='[F4] Baudrate', children=[ MenuItem( str(bd), handler=(lambda bd: lambda: self.baudrate_update( baudrate=int(bd)))(bd)) for bd in BAUDRATE ], ), MenuItem( text='[F5] End line', children=[ MenuItem('LF \\n', handler=lambda: self.end_line_update('\n')), MenuItem('CR \\r', handler=lambda: self.end_line_update('\r')), MenuItem('CRLF \\r\\n', handler=lambda: self.end_line_update('\r\n')), ], ), MenuItem(text='[F10] Quit', handler=self.key_application_quit), ], ) if args.end_line == 'LF': self.end_line = '\n' elif args.end_line == 'CR': self.end_line = '\r' else: self.end_line = '\r\n' self.end_line_update() self.baudrate_update() self.layout = Layout(self.menu) self.layout.focus(self.root) self.key_bindings.add('s-tab')(focus_previous) self.key_bindings.add('tab')(focus_next) self.app = Application( layout=self.layout, key_bindings=self.key_bindings, full_screen=True, mouse_support=True, ) @key_bindings.add('f10') @key_bindings.add('c-c') @key_bindings.add('c-d') @key_bindings.add('c-x') @key_bindings.add('c-q') @key_bindings.add('escape') def key_application_quit(self, event=None): get_app().exit() @key_bindings.add('f2') def key_uart_open(self, event=None): UART.run() @key_bindings.add('f3') def key_uart_close(self, event=None): UART.stop() @key_bindings.add('f4') def key_baudrate(self, event=None): tui.baudrate_update(shift=True) @key_bindings.add('f5') def key_end_line(self, event=None): tui.end_line_update(shift=True) def cmd_line_accept_handler(self, handler): line = ''.join(('Tx: ', handler.text)) console_append(self.console, line) UART.queue_tx.put_nowait(handler.text + self.end_line) def end_line_update(self, symbol=None, shift=None): if symbol: self.end_line = symbol if shift: if self.end_line == '\n': self.end_line = '\r' self.menu.menu_items[-2].text = '[F5] End line CR' elif self.end_line == '\r': self.end_line = '\r\n' self.menu.menu_items[-2].text = '[F5] End line CRLF' else: self.end_line = '\n' self.menu.menu_items[-2].text = '[F5] End line LF' if self.end_line == '\n': self.menu.menu_items[-2].text = '[F5] End line LF' elif self.end_line == '\r': self.menu.menu_items[-2].text = '[F5] End line CR' else: self.menu.menu_items[-2].text = '[F5] End line CRLF' def baudrate_update(self, baudrate=None, shift=None): if baudrate in BAUDRATE: UART.baudrate = baudrate UART.stop() UART.run() if shift: i = BAUDRATE.index(UART.baudrate) i = 0 if i + 1 >= len(BAUDRATE) else i + 1 UART.baudrate = BAUDRATE[i] UART.stop() UART.run() self.menu.menu_items[-3].text = '[F4] Baudrate ' + str(UART.baudrate) def run(self): prompt_toolkit.eventloop.use_asyncio_event_loop() asyncio.get_event_loop().run_until_complete( self.app.run_async().to_asyncio_future())
class TUI: def __init__(self): # Map JobBase.name to SimpleNamespace with attributes: # job - JobBase instance # widget - JobWidgetBase instance # container - Container instance self._jobs = collections.defaultdict(lambda: types.SimpleNamespace()) self._app = self._make_app() self._app_terminated = False self._exception = None utils.get_aioloop().set_exception_handler(self._handle_exception) def _handle_exception(self, loop, context): exception = context.get('exception') if exception: _log.debug('Caught unhandled exception: %r', exception) if not self._exception: self._exception = exception self._exit() def _make_app(self): self._jobs_container = HSplit( # FIXME: Layout does not accept an empty list of children. We add an # empty Window that doesn't display anything that gets # removed automatically when we rebuild # self._jobs_container.children. # https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1257 children=[Window()], style='class:default', ) self._layout = Layout(self._jobs_container) kb = KeyBindings() @kb.add('escape') @kb.add('c-g') @kb.add('c-q') @kb.add('c-c') def _(event, self=self): if self._app.is_running: self._exit() app = Application( layout=self._layout, key_bindings=kb, style=style.style, full_screen=False, erase_when_done=False, mouse_support=False, on_invalidate=self._update_jobs_container, ) # Make escape key work app.timeoutlen = 0.1 app.ttimeoutlen = 0.1 return app def add_jobs(self, *jobs): """Add :class:`~.jobs.base.JobBase` instances""" for job in jobs: if job.name in self._jobs: raise RuntimeError(f'Job was already added: {job.name}') else: self._jobs[job.name].job = job self._jobs[job.name].widget = jobwidgets.JobWidget(job, self._app) self._jobs[job.name].container = to_container(self._jobs[job.name].widget) # Terminate application if all jobs finished job.signal.register('finished', self._exit_if_all_jobs_finished) # Terminate application if any job finished with non-zero exit code job.signal.register('finished', self._exit_if_job_failed) if self._jobs[job.name].widget.is_interactive: # Display next interactive job when currently focused job finishes job.signal.register('finished', self._update_jobs_container) self._update_jobs_container() def _update_jobs_container(self, *_): job_containers = [] # Start all enabled jobs that are supposed to start automatically for jobinfo in self._enabled_jobs: if not jobinfo.job.is_started and jobinfo.job.autostart: jobinfo.job.start() # List interactive jobs first for jobinfo in self._enabled_jobs: if jobinfo.widget.is_interactive: job_containers.append(jobinfo.container) # Focus the first unfinished job if not jobinfo.job.is_finished: try: self._layout.focus(jobinfo.container) except ValueError: pass # _log.debug('Failed to focus job: %r', jobinfo.job.name) # else: # _log.debug('Focused job: %r', jobinfo.job.name) # Don't display more than one unfinished interactive job # unless any job has errors, in which case we are # terminating the application and display all jobs. if not any(jobinfo.job.errors for jobinfo in self._jobs.values()): break # Add non-interactive jobs below interactive jobs so the interactive # widgets don't change position when non-interactive widgets change # size. for jobinfo in self._enabled_jobs: if not jobinfo.widget.is_interactive: job_containers.append(jobinfo.container) # Replace visible containers self._jobs_container.children[:] = job_containers @property def _enabled_jobs(self): return tuple(jobinfo for jobinfo in self._jobs.values() if jobinfo.job.is_enabled) def run(self, jobs): """ Block while running `jobs` :param jobs: Iterable of :class:`~.jobs.base.JobBase` instances :raise: Any exception that occured while running jobs :return: :attr:`~.JobBase.exit_code` from the first failed job or 0 for success """ self.add_jobs(*jobs) # Block until _exit() is called self._app.run(set_exception_handler=False) exception = self._get_exception() if exception: _log.debug('Application exception: %r', exception) raise exception else: # First non-zero exit_code is the application exit_code for jobinfo in self._enabled_jobs: _log.debug('Checking exit_code of %r: %r', jobinfo.job.name, jobinfo.job.exit_code) if jobinfo.job.exit_code != 0: return jobinfo.job.exit_code return 0 def _exit_if_all_jobs_finished(self, *_): if all(jobinfo.job.is_finished for jobinfo in self._enabled_jobs): _log.debug('All jobs finished') self._exit() def _exit_if_job_failed(self, job): if job.is_finished and job.exit_code != 0: _log.debug('Terminating application because of failed job: %r', job.name) self._exit() def _exit(self): if not self._app_terminated: if not self._app.is_running and not self._app.is_done: utils.get_aioloop().call_soon(self._exit) else: def handle_jobs_terminated(task): try: task.result() except BaseException as e: _log.debug('Handling exception from %r', task) self._exception = e finally: _log.debug('Calling %r', self._app.exit) self._app.exit() self._update_jobs_container() self._app_terminated = True task = self._app.create_background_task(self._terminate_jobs()) task.add_done_callback(handle_jobs_terminated) async def _terminate_jobs(self, callback=None): _log.debug('Waiting for jobs before exiting') self._finish_jobs() for jobinfo in self._enabled_jobs: if jobinfo.job.is_started and not jobinfo.job.is_finished: _log.debug('Waiting for %r', jobinfo.job.name) await jobinfo.job.wait() _log.debug('Done waiting for %r', jobinfo.job.name) if callback: callback() def _finish_jobs(self): for jobinfo in self._enabled_jobs: if not jobinfo.job.is_finished: _log.debug('Finishing %s', jobinfo.job.name) jobinfo.job.finish() def _get_exception(self): if self._exception: # Exception from _handle_exception() return self._exception else: # First exception from jobs for jobinfo in self._enabled_jobs: if jobinfo.job.raised: _log.debug('Exception from %s: %r', jobinfo.job.name, jobinfo.job.raised) return jobinfo.job.raised
class HspApp(Application): """prompt_toolkit application for controlling and displaying a high-speed playback Attributes ========== displayingHelpScreen : bool used to toggle between help screen and normal view disabled_bindings : bool used to toggle key_bindings save_location : str Name preamble used when saving playback files playback : hsp.Playback Playback object that is being controlled by the app command_cache : collections.deque Local reference to the most recent command objects from playback hist main_view : prompt_toolkit.layout.containers.HSplit main layout for the app Methods ======= mainViewCondition() init_bindings(self, bindings) Adds custom key_bindings to the app toolbar_text(self) Returns bottom toolbar for app render_command(self, command) Return string of command object specific to this UI get_user_comment(self) Modifies the display to add an area to enter a comment for a command _set_user_comment(self, buff) Callback fuction from the BufferControl created for user comments update_display displays last N commands in the local cache Async Methods ============= command_loop(self) Primary loop for receiving/displaying commands from playback redraw_timer(self) Async method to force a redraw of the app every hundreth second """ def __init__(self, playback, save_location=None, *args, **kwargs): self.mainViewCondition = partial(self.mainView, self) self.mainViewCondition = Condition(self.mainViewCondition) self.disabled_bindings = False bindings = KeyBindings() self.init_bindings(bindings) super().__init__(full_screen=True, key_bindings=bindings, mouse_support=True, *args, **kwargs) self.displayingHelpScreen = ( False) # used to toggle between help screen on normal if save_location: self.save_location = save_location else: self.save_location = SAVE_LOCATION self.playback = playback self._savedLayout = Layout(Window()) self.command_cache = deque([], maxlen=5) ########################################## ### Setting up views ########################################## self.old_command_window = FormattedTextControl(text="Output goes here", focusable=True) self.new_command_window = FormattedTextControl(text="Output goes here", focusable=True) self.body = Frame( HSplit([ Frame(Window(self.old_command_window)), Frame(Window(self.new_command_window)), ])) self.toolbar = Window( FormattedTextControl(text=self.toolbar_text), height=Dimension(max=1, weight=10000), dont_extend_height=True, ) self.main_view = HSplit([self.body, self.toolbar], padding_char="-") self.layout = Layout(self.main_view) @staticmethod def mainView(self): """Return if app is in main view. Returns ======= _ : bool If app is not displaying the Help Screen or comment, it's in main view """ disable = self.displayingHelpScreen or self.disabled_bindings return not disable @contextmanager def switched_layout(self): """Context manager to stores and restore the current layout Saves the current layout to an instance variable and then restores that layout on __exit__ """ self._saved_layout = self.layout yield self.layout = self._saved_layout self.invalidate() @contextmanager def paused_playback(self): """Context manager for pausing playback temporarily Pauses the playback in the __enter__ section. If the playback was originally paused, the __exit__ section will keep the playback paused, otherwise it will resume play of the playback """ _originally_paused = self.playback.paused self.playback.pause() yield if not _originally_paused: self.playback.play() @contextmanager def bindings_off(self): """Context manager for disabling key_bindings for a given scope Sets disabled_bindings to True so that the mainView Condition will return False. Upon __exit__, it returns the key_ binding state to what is was when it entered. """ _originally_disabled = self.disabled_bindings self.disabled_bindings = True yield self.disabled_bindings = _originally_disabled def init_bindings(self, bindings): """Adds custom key_bindings to the app """ @bindings.add("n", filter=self.mainViewCondition) @bindings.add("down", filter=self.mainViewCondition) @bindings.add("right", filter=self.mainViewCondition) def _(event): try: self.playback.loop_lock.release() except Exception as e: pass @bindings.add("p", filter=self.mainViewCondition) def _(event): if self.playback.paused: self.playback.play() else: self.playback.pause() @bindings.add("f", filter=self.mainViewCondition) def _(event): self.playback.speedup() @bindings.add("s", filter=self.mainViewCondition) def _(event): self.playback.slowdown() @bindings.add("q") @bindings.add("c-c") def _(event): event.app.exit() @bindings.add("c-m", filter=self.mainViewCondition) def _(event): self.playback.change_playback_mode() @bindings.add("c", filter=self.mainViewCondition) def _(event): self.get_user_comment() self.update_display() @bindings.add("c-f", filter=self.mainViewCondition) def _(event): # set the flag in both the self.playback and the local cache for display self.playback.flag_current_command() self.update_display() @bindings.add("c-s", filter=self.mainViewCondition) def _(event): time = datetime.datetime.now() with open(self.save_location + f"_{time.strftime('%Y%m%d%H%M')}", "wb+") as outfi: pickle.dump(self.playback.hist, outfi) @bindings.add("g", filter=self.mainViewCondition) def _(event): # future: goto time pass @bindings.add("h") def _(event): # display help screen if event.app.displayingHelpScreen: # exit help screen event.app.displayingHelpScreen = False self.playback.pause() event.app.layout = event.app.savedLayout event.app.invalidate() else: # display help screen event.app.displayingHelpScreen = True event.app.savedLayout = event.app.layout event.app.layout = self.helpLayout event.app.invalidate() helpLayout = Layout( Frame( Window( FormattedTextControl( "HELP SCREEN\n\n" "h - help screen\n" "s - slow down\n" "p - toggle play/pause\n" "c - add comment to current command\n" "g - goto specific time in history\n" "ctrl-m change self.playback mode\n" "ctrl-f flag event\n" "ctrl-s save playback object to file\n" "n/dwn/rght next event\n")))) def toolbar_text(self): """Returns bottom toolbar for app Returns ======= _ : prompt_toolkit.formatted_text.HTML Text for the bottom toolbar for the app """ if self.playback.playback_mode == self.playback.EVENINTERVAL: return HTML( "<table><tr>" f"<th>PLAYBACK TIME: {self.playback.current_time.strftime('%b %d %Y %H:%M:%S')}</th> " f"<th>PLAYBACK MODE: {self.playback.playback_mode}</th> " f"<th>PAUSED: {self.playback.paused}</th> " f"<th>PLAYBACK INTERVAL: {self.playback.playback_interval}s</th>" "</tr></table>") else: return HTML( "<table><tr>" f"<th>PLAYBACK TIME: {self.playback.current_time.strftime('%b %d %Y %H:%M:%S')}</th> " f"<th>PLAYBACK MODE: {self.playback.playback_mode}</th> " f"<th>PAUSED: {self.playback.paused}</th> " f"<th>PLAYBACK RATE: {self.playback.playback_rate}</th>" "</tr></table>") def render_command(self, command): """Return string of command object specific to this UI Parameters ========== command : command.Command Command object to get string for Returns ======= _ : str String representation of Command object """ try: if command.flagged: color = "ansired" else: color = "ansiwhite" except: color = "ansiwhite" try: return [ ("bg:ansiblue ansiwhite", f"{command.time.ctime()}\n"), ( color, (f"{command.hostUUID}:{command.user} > {command.command}\n" f"{command.result}" f"{command.comment}"), ), ] except: # if this happens, we probaly didn't get an actual Command object # but we can have it rendered in the window anyway return [(color, str(command))] def get_user_comment(self): """Modifies the display to add an area to enter a comment for a command Creates a BufferControl in a Frame and replaces the toolbar with the Frame #bug: the new toolbar is unable to get focus right away; it requires the user to click in the area """ self._savedLayout = self.layout self.disabled_bindings = True commentControl = BufferControl( Buffer(accept_handler=self._set_user_comment), focus_on_click=True) user_in_area = Frame( Window( commentControl, height=Dimension(max=1, weight=10000), dont_extend_height=True, ), title="Enter Comment (alt-Enter to submit)", ) self.toolbar = user_in_area self.main_view = HSplit([self.body, self.toolbar], padding_char="-") self.layout = Layout(self.main_view, focused_element=user_in_area.body) self.layout.focus(user_in_area.body) self.invalidate() def _set_user_comment(self, buff): """Callback fuction from the BufferControl created for user comments Takes the user comment and sets that as the current command's comment. Then replaces the original layout. """ self.playback.hist[self.playback.playback_position - 1].comment = buff.text self.disabled_bindings = False self.layout = self._savedLayout self.update_display() self.invalidate() def update_display(self): """displays last N commands in the local cache This should only be called when the main display with command history is showing otherwise the requisite windows will not be focusable. #future: allow the number of commands displayed to grow to the size of the available screen realastate """ self.layout.focus(self.old_command_window) if len(self.command_cache) > 1: self.layout.current_control.text = self.render_command( self.command_cache[-2]) else: self.layout.current_control.text = "COMMAND OUTPUT HERE" # self.layout.current_control.text = new_command_window.text self.layout.focus(self.new_command_window) if len(self.command_cache) > 0: self.layout.current_control.text = self.render_command( self.command_cache[-1]) else: self.layout.current_control.text = "COMMAND OUTPUT HERE" self.invalidate() ################################################### # Setting Up Loop to async iter over history ################################################### async def command_loop(self): """Primary loop for receiving/displaying commands from playback Asynchronously iterates over the Command objects in the playback's history. Takes the command object and displays it to the screen """ # give this thread control over playback for manual mode # lock is released by certain key bindings await self.playback.loop_lock.acquire() async for command in self.playback: if self.playback.playback_mode == "MANUAL": # regain the lock for MANUAL mode await self.playback.loop_lock.acquire() self.command_cache.append(command) # Update text in windows self.update_display() else: # future: fix; this will fail at the end of a playback history self.command_cache.append(command) self.update_display() async def redraw_timer(self): """Async method to force a redraw of the app every hundreth second Never terminates # future: do some more checking and error handling """ while True: await asyncio.sleep(0.01) self.invalidate()
from xradios.tui.buffers.display import buffer as display_buffer from xradios.tui.buffers.popup import buffer as popup_buffer from xradios.tui.buffers.prompt import buffer as prompt_buffer from xradios.tui.buffers.listview import buffer as listview_buffer layout = Layout( FloatContainer( content=HSplit([ TopBar(message="Need help! Press `F1`."), Display(display_buffer), ListView(listview_buffer), Prompt(prompt_buffer), ]), modal=True, floats=[ # Help text as a float. Float( top=3, bottom=2, left=2, right=2, content=ConditionalContainer( content=PopupWindow(popup_buffer, title="Help"), filter=has_focus(popup_buffer), ), ) ], )) layout.focus(prompt_buffer)