diff options
-rw-r--r-- | alot/commands/__init__.py | 27 | ||||
-rw-r--r-- | alot/commands/globals.py | 28 | ||||
-rw-r--r-- | alot/defaults/alot.rc.spec | 13 | ||||
-rw-r--r-- | alot/defaults/default.bindings | 13 | ||||
-rw-r--r-- | alot/ui.py | 266 | ||||
-rw-r--r-- | alot/widgets/globals.py | 8 | ||||
-rw-r--r-- | alot/widgets/utils.py | 20 | ||||
-rw-r--r-- | docs/source/api/interface.rst | 17 | ||||
-rw-r--r-- | docs/source/conf.py | 1 |
9 files changed, 187 insertions, 206 deletions
diff --git a/alot/commands/__init__.py b/alot/commands/__init__.py index d3a052d0..2fd61b84 100644 --- a/alot/commands/__init__.py +++ b/alot/commands/__init__.py @@ -14,17 +14,9 @@ from alot.helper import split_commandstring class Command(object): """base class for commands""" - def __init__(self, prehook=None, posthook=None): - """ - :param prehook: name of the hook to call directly before - applying this command - :type prehook: str - :param posthook: name of the hook to call directly after - applying this command - :type posthook: str - """ - self.prehook = prehook - self.posthook = posthook + def __init__(self): + self.prehook = None + self.posthook = None self.undoable = False self.help = self.__doc__ @@ -191,17 +183,20 @@ def commandfactory(cmdline, mode='global'): parms = vars(parser.parse_args(args)) parms.update(forcedparms) - logging.debug('PARMS: %s' % parms) + + logging.debug('cmd parms %s' % parms) + + # create Command + cmd = cmdclass(**parms) # set pre and post command hooks get_hook = settings.get_hook - parms['prehook'] = get_hook('pre_%s_%s' % (mode, cmdname)) or \ + cmd.prehook = get_hook('pre_%s_%s' % (mode, cmdname)) or \ get_hook('pre_global_%s' % cmdname) - parms['posthook'] = get_hook('post_%s_%s' % (mode, cmdname)) or \ + cmd.posthook = get_hook('post_%s_%s' % (mode, cmdname)) or \ get_hook('post_global_%s' % cmdname) - logging.debug('cmd parms %s' % parms) - return cmdclass(**parms) + return cmd pyfiles = glob.glob1(os.path.dirname(__file__), '*.py') diff --git a/alot/commands/globals.py b/alot/commands/globals.py index 02579519..db1c764a 100644 --- a/alot/commands/globals.py +++ b/alot/commands/globals.py @@ -56,6 +56,9 @@ class SearchCommand(Command): """ :param query: notmuch querystring :type query: str + :param sort: how to order results. Must be one of + 'oldest_first', 'newest_first', 'message_id' or 'unsorted'. + :type sort: str """ self.query = ' '.join(query) self.order = sort @@ -508,18 +511,17 @@ class HelpCommand(Command): linewidgets.append(line) body = urwid.ListBox(linewidgets) - ckey = 'cancel' - titletext = 'Bindings Help (%s cancels)' % ckey + titletext = 'Bindings Help (escape cancels)' box = DialogBox(body, titletext, bodyattr=text_att, titleattr=title_att) # put promptwidget as overlay on main widget - overlay = urwid.Overlay(box, ui.mainframe_themed, 'center', + overlay = urwid.Overlay(box, ui.root_widget, 'center', ('relative', 70), 'middle', ('relative', 70)) - ui.show_as_root_until_keypress(overlay, 'cancel') + ui.show_as_root_until_keypress(overlay, 'esc') else: logging.debug('HELP %s' % self.commandname) parser = commands.lookup_parser(self.commandname, ui.mode) @@ -732,21 +734,3 @@ class ComposeCommand(Command): cmd = commands.envelope.EditCommand(envelope=self.envelope, spawn=self.force_spawn, refocus=False) ui.apply_command(cmd) - - -@registerCommand(MODE, 'move', help='move focus', arguments=[ - (['key'], {'nargs':'+', 'help':'direction'})]) -@registerCommand(MODE, 'cancel', help='send cancel event', - forced={'key': 'cancel'}) -@registerCommand(MODE, 'select', help='send select event', - forced={'key': 'select'}) -class SendKeypressCommand(Command): - """send a keypress to the main widget to be processed by urwid""" - def __init__(self, key, **kwargs): - Command.__init__(self, **kwargs) - if isinstance(key, list): - key = ' '.join(key) - self.key = key - - def apply(self, ui): - ui.keypress(self.key) diff --git a/alot/defaults/alot.rc.spec b/alot/defaults/alot.rc.spec index 174413fd..63689e9f 100644 --- a/alot/defaults/alot.rc.spec +++ b/alot/defaults/alot.rc.spec @@ -4,6 +4,9 @@ ask_subject = boolean(default=True) # ask for subject when compose # directory prefix for downloading attachments attachment_prefix = string(default='~') +# timeout in (floating point) seconds until partial input is cleared +input_timeout = float(default=1.0) + # confirm exit bug_on_exit = boolean(default=False) @@ -88,7 +91,7 @@ show_statusbar = boolean(default=True) # * `{buffer_no}`: index of this buffer in the global buffer list # * `{total_messages}`: total numer of messages indexed by notmuch # * `{pending_writes}`: number of pending write operations to the index -bufferlist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: bufferlist]','total messages: {total_messages}')) +bufferlist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: bufferlist]','{input_queue} total messages: {total_messages}')) # Format of the status-bar in search mode. # This is a pair of strings to be left and right aligned in the status-bar. @@ -98,7 +101,7 @@ bufferlist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: bu # * `{querystring}`: search string # * `{result_count}`: number of matching messages # * `{result_count_positive}`: 's' if result count is greater than 0. -search_statusbar = mixed_list(string, string, default=list('[{buffer_no}: search] for "{querystring}"','{result_count} of {total_messages} messages')) +search_statusbar = mixed_list(string, string, default=list('[{buffer_no}: search] for "{querystring}"','{input_queue} {result_count} of {total_messages} messages')) # Format of the status-bar in thread mode. # This is a pair of strings to be left and right aligned in the status-bar. @@ -109,13 +112,13 @@ search_statusbar = mixed_list(string, string, default=list('[{buffer_no}: search # * `{subject}`: subject line of the thread # * `{authors}`: abbreviated authors string for this thread # * `{message_count}`: number of contained messages -thread_statusbar = mixed_list(string, string, default=list('[{buffer_no}: thread] {subject}','total messages: {total_messages}')) +thread_statusbar = mixed_list(string, string, default=list('[{buffer_no}: thread] {subject}','{input_queue} total messages: {total_messages}')) # Format of the status-bar in taglist mode. # This is a pair of strings to be left and right aligned in the status-bar. # These strings may contain variables listed at :ref:`bufferlist_statusbar <bufferlist-statusbar>` # that will be substituted accordingly. -taglist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: taglist]','total messages: {total_messages}')) +taglist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: taglist]','{input_queue} total messages: {total_messages}')) # Format of the status-bar in envelope mode. # This is a pair of strings to be left and right aligned in the status-bar. @@ -123,7 +126,7 @@ taglist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: tagli # these strings may contain variables: # # * `{to}`: To-header of the envelope -envelope_statusbar = mixed_list(string, string, default=list('[{buffer_no}: envelope]','total messages: {total_messages}')) +envelope_statusbar = mixed_list(string, string, default=list('[{buffer_no}: envelope]','{input_queue} total messages: {total_messages}')) # timestamp format in `strftime format syntax <http://docs.python.org/library/datetime.html#strftime-strptime-behavior>`_ timestamp_format = string(default=None) diff --git a/alot/defaults/default.bindings b/alot/defaults/default.bindings index 204f3cb6..71a53d14 100644 --- a/alot/defaults/default.bindings +++ b/alot/defaults/default.bindings @@ -1,8 +1,10 @@ +up = move up +down = move down +page up = move page up +page down = move page down j = move down k = move up ' ' = move page down -esc = cancel -enter = select @ = refresh ? = help bindings I = search tag:inbox AND NOT tag:killed @@ -21,9 +23,10 @@ q = exit [bufferlist] x = close - select = openfocussed + enter = openfocussed [search] + enter = select a = toggletags inbox & = toggletags killed ! = toggletags flagged @@ -41,12 +44,14 @@ q = exit b = 'refine Bcc' c = 'refine Cc' S = togglesign - select = edit + enter = edit H = toggleheaders [taglist] + enter = select [thread] + enter = select C = fold --all E = unfold --all c = fold @@ -7,62 +7,13 @@ from twisted.internet import reactor, defer from settings import settings from buffers import BufferlistBuffer -import commands from commands import commandfactory from alot.commands import CommandParseError from alot.helper import string_decode -from alot.widgets.utils import CatchKeyWidgetWrap from alot.widgets.globals import CompleteEdit from alot.widgets.globals import ChoiceWidget -class InputWrap(urwid.WidgetWrap): - """ - This is the topmost widget used in the widget tree. - Its purpose is to capture and interpret keypresses - by instantiating and applying the relevant :class:`Command` objects - or relaying them to the wrapped `rootwidget`. - """ - def __init__(self, ui, rootwidget): - urwid.WidgetWrap.__init__(self, rootwidget) - self.ui = ui - self.rootwidget = rootwidget - self.select_cancel_only = False - - def set_root(self, w): - self._w = w - - def get_root(self): - return self._w - - def allowed_command(self, cmd): - """sanity check if the given command should be applied. - This is used in :meth:`keypress`""" - if not self.select_cancel_only: - return True - elif isinstance(cmd, commands.globals.SendKeypressCommand): - if cmd.key in ['select', 'cancel']: - return True - else: - return False - - def keypress(self, size, key): - """overwrites `urwid.WidgetWrap.keypress`""" - mode = self.ui.mode - if self.select_cancel_only: - mode = 'global' - cmdline = settings.get_keybinding(mode, key) - if cmdline: - try: - cmd = commandfactory(cmdline, mode) - if self.allowed_command(cmd): - self.ui.apply_command(cmd) - return None - except CommandParseError, e: - self.ui.notify(e.message, priority='error') - return self._w.keypress(size, key) - - class UI(object): """ This class integrates all components of alot and offers @@ -75,7 +26,13 @@ class UI(object): current_buffer = None """points to currently active :class:`~alot.buffers.Buffer`""" dbman = None - """Database manager (:class:`~alot.db.DBManager`)""" + """Database Manager (:class:`~alot.db.manager.DBManager`)""" + mode = None + """interface mode identifier - type of current buffer""" + commandprompthistory = [] + """history of the command line prompt""" + input_queue = [] + """stores partial keyboard input""" def __init__(self, dbman, initialcmd): """ @@ -85,50 +42,124 @@ class UI(object): :param colourmode: determines which theme to chose :type colourmode: int in [1,16,256] """ + # store database manager self.dbman = dbman - - colourmode = int(settings.get('colourmode')) - logging.info('setup gui in %d colours' % colourmode) + # define empty notification pile + self._notificationbar = None + # should we show a status bar? + self._show_statusbar = settings.get('show_statusbar') + # pass keypresses to the root widget and never interpret bindings + self._passall = False + # indicates "input lock": only focus move commands are interpreted + self._locked = False + self._unlock_callback = None # will be called after input lock ended + self._unlock_key = None # key that ends input lock + + # alarm handle for callback that clears input queue (to cancel alarm) + self._alarm = None + + # create root widget global_att = settings.get_theming_attribute('global', 'body') - self.mainframe = urwid.Frame(urwid.SolidFill()) - self.mainframe_themed = urwid.AttrMap(self.mainframe, global_att) - self.inputwrap = InputWrap(self, self.mainframe_themed) - self.mainloop = urwid.MainLoop(self.inputwrap, + mainframe = urwid.Frame(urwid.SolidFill()) + self.root_widget = urwid.AttrMap(mainframe, global_att) + + # set up main loop + self.mainloop = urwid.MainLoop(self.root_widget, handle_mouse=False, event_loop=urwid.TwistedEventLoop(), - unhandled_input=self.unhandeled_input) - self.mainloop.screen.set_terminal_properties(colors=colourmode) + unhandled_input=self._unhandeled_input, + input_filter=self._input_filter) - self.show_statusbar = settings.get('show_statusbar') - self.notificationbar = None - self.mode = 'global' - self.commandprompthistory = [] + # set up colours + colourmode = int(settings.get('colourmode')) + logging.info('setup gui in %d colours' % colourmode) + self.mainloop.screen.set_terminal_properties(colors=colourmode) logging.debug('fire first command') self.apply_command(initialcmd) + + # start urwids mainloop self.mainloop.run() - def unhandeled_input(self, key): - """called if a keypress is not handled.""" + def _input_filter(self, keys, raw): + """ + handles keypresses. + This function gets triggered directly by class:`urwid.MainLoop` + upon user input and is supposed to pass on its `keys` parameter + to let the root widget handle keys. We intercept the input here + to trigger custom commands as defined in our keybindings. + """ + logging.debug("Got key (%s, %s)" % (keys, raw)) + # work around: escape triggers this twice, with keys = raw = [] + # the first time.. + if not keys: + return + # let widgets handle input if key is virtual window resize keypress + # or we are in "passall" mode + elif 'window resize' in keys or self._passall: + return keys + # end "lockdown" mode if the right key was pressed + elif self._locked and keys[0] == self._unlock_key: + self._locked = False + self.mainloop.widget = self.root_widget + if callable(self._unlock_callback): + self._unlock_callback() + # otherwise interpret keybinding + else: + # define callback that resets input queue + def clear(*args): + if self._alarm is not None: + self.mainloop.remove_alarm(self._alarm) + self.input_queue = [] + self.update() + + key = keys[0] + self.input_queue.append(key) + keyseq = ' '.join(self.input_queue) + cmdline = settings.get_keybinding(self.mode, keyseq) + if cmdline: + logging.debug("cmdline: '%s'" % cmdline) + # move keys are always passed + if cmdline.startswith('move '): + movecmd = cmdline[5:].rstrip() + logging.debug("GOT MOVE: '%s'" % movecmd) + if movecmd in ['up', 'down', 'page up', 'page down']: + clear() + return [movecmd] + elif not self._locked: + try: + clear() + cmd = commandfactory(cmdline, self.mode) + self.apply_command(cmd) + except CommandParseError, e: + self.notify(e.message, priority='error') + + timeout = float(settings.get('input_timeout')) + if self._alarm is not None: + self.mainloop.remove_alarm(self._alarm) + self._alarm = self.mainloop.set_alarm_in(timeout, clear) + # update statusbar + self.update() + + def _unhandeled_input(self, key): + """ + Called by :class:`urwid.MainLoop` if a keypress was passed to the root widget by + `self._input_filter` but is not handled in any widget. + We keep it for debuging purposes. + """ logging.debug('unhandled input: %s' % key) - def keypress(self, key): - """relay all keypresses to our `InputWrap`""" - self.inputwrap.keypress((150, 20), key) - - def show_as_root_until_keypress(self, w, key, relay_rest=True, - afterwards=None): - def oe(): - self.inputwrap.set_root(self.mainframe_themed) - self.inputwrap.select_cancel_only = False - if callable(afterwards): - logging.debug('called') - afterwards() - logging.debug('relay: %s' % relay_rest) - helpwrap = CatchKeyWidgetWrap(w, key, on_catch=oe, - relay_rest=relay_rest) - self.inputwrap.set_root(helpwrap) - self.inputwrap.select_cancel_only = not relay_rest + def show_as_root_until_keypress(self, w, key, afterwards=None): + """ + Replaces root widget by given :class:`urwid.Widget` and makes the UI + ignore all further commands apart from cursor movement. + If later on `key` is pressed, the old root widget is reset, callable + `afterwards` is called and normal behaviour is resumed. + """ + self.mainloop.widget = w + self._unlock_key = key + self._unlock_callback = afterwards + self._locked = True def prompt(self, prefix, text=u'', completer=None, tab=0, history=[]): """prompt for text input @@ -144,16 +175,16 @@ class UI(object): :type tab: int :param history: history to be used for up/down keys :type history: list of str - :returns: a :class:`twisted.defer.Deferred` + :rtype: :class:`twisted.defer.Deferred` """ d = defer.Deferred() # create return deferred - oldroot = self.inputwrap.get_root() + oldroot = self.mainloop.widget def select_or_cancel(text): # restore main screen and invoke callback # (delayed return) with given text - self.inputwrap.set_root(oldroot) - self.inputwrap.select_cancel_only = False + self.mainloop.widget = oldroot + self._passall = False d.callback(text) prefix = prefix + settings.get('prompt_suffix') @@ -181,14 +212,14 @@ class UI(object): ('fixed right', 0), ('fixed bottom', 1), None) - self.inputwrap.set_root(overlay) - self.inputwrap.select_cancel_only = True + self.mainloop.widget = overlay + self._passall = True return d # return deferred def exit(self): """ shuts down user interface without cleaning up. - Use a :class:`commands.globals.ExitCommand` for a clean shutdown. + Use a :class:`alot.commands.globals.ExitCommand` for a clean shutdown. """ exit_msg = None try: @@ -239,7 +270,6 @@ class UI(object): else: if self.current_buffer != buf: self.current_buffer = buf - self.inputwrap.set_root(self.mainframe_themed) self.mode = buf.modename if isinstance(self.current_buffer, BufferlistBuffer): self.current_buffer.rebuild() @@ -260,26 +290,26 @@ class UI(object): def get_buffers_of_type(self, t): """ returns currently open buffers for a given subclass of - :class:`alot.buffer.Buffer` + :class:`~alot.buffers.Buffer` """ return filter(lambda x: isinstance(x, t), self.buffers) def clear_notify(self, messages): """ - clears notification popups. Call this to ged rid of messages that don't + Clears notification popups. Call this to ged rid of messages that don't time out. :param messages: The popups to remove. This should be exactly what :meth:`notify` returned when creating the popup """ - newpile = self.notificationbar.widget_list + newpile = self._notificationbar.widget_list for l in messages: if l in newpile: newpile.remove(l) if newpile: - self.notificationbar = urwid.Pile(newpile) + self._notificationbar = urwid.Pile(newpile) else: - self.notificationbar = None + self._notificationbar = None self.update() def choice(self, message, choices={'y': 'yes', 'n': 'no'}, @@ -300,18 +330,18 @@ class UI(object): :param msg_position: determines if `message` is above or left of the prompt. Must be `above` or `left`. :type msg_position: str - :returns: a :class:`twisted.defer.Deferred` + :rtype: :class:`twisted.defer.Deferred` """ assert select in choices.values() + [None] assert cancel in choices.values() + [None] assert msg_position in ['left', 'above'] d = defer.Deferred() # create return deferred - oldroot = self.inputwrap.get_root() + oldroot = self.mainloop.widget def select_or_cancel(text): - self.inputwrap.set_root(oldroot) - self.inputwrap.select_cancel_only = False + self.mainloop.widget = oldroot + self._passall = False d.callback(text) #set up widgets @@ -337,8 +367,8 @@ class UI(object): ('fixed right', 0), ('fixed bottom', 1), None) - self.inputwrap.set_root(overlay) - self.inputwrap.select_cancel_only = True + self.mainloop.widget = overlay + self._passall = True return d # return deferred def notify(self, message, priority='normal', timeout=0, block=False): @@ -367,11 +397,11 @@ class UI(object): return urwid.AttrMap(cols, att) msgs = [build_line(message, priority)] - if not self.notificationbar: - self.notificationbar = urwid.Pile(msgs) + if not self._notificationbar: + self._notificationbar = urwid.Pile(msgs) else: - newpile = self.notificationbar.widget_list + msgs - self.notificationbar = urwid.Pile(newpile) + newpile = self._notificationbar.widget_list + msgs + self._notificationbar = urwid.Pile(newpile) self.update() def clear(*args): @@ -379,14 +409,13 @@ class UI(object): if block: # put "cancel to continue" widget as overlay on main widget - txt = build_line('(cancel continues)', priority) - overlay = urwid.Overlay(txt, self.mainframe_themed, + txt = build_line('(escape continues)', priority) + overlay = urwid.Overlay(txt, self.root_widget, ('fixed left', 0), ('fixed right', 0), ('fixed bottom', 0), None) - self.show_as_root_until_keypress(overlay, 'cancel', - relay_rest=False, + self.show_as_root_until_keypress(overlay, 'esc', afterwards=clear) else: if timeout >= 0: @@ -397,26 +426,24 @@ class UI(object): def update(self): """redraw interface""" - #who needs a header? - #head = urwid.Text('notmuch gui') - #h=urwid.AttrMap(head, 'header') - #self.mainframe.set_header(h) + # get the main urwid.Frame widget + mainframe = self.root_widget.original_widget # body if self.current_buffer: - self.mainframe.set_body(self.current_buffer) + mainframe.set_body(self.current_buffer) # footer lines = [] - if self.notificationbar: # .get_text()[0] != ' ': - lines.append(self.notificationbar) - if self.show_statusbar: + if self._notificationbar: # .get_text()[0] != ' ': + lines.append(self._notificationbar) + if self._show_statusbar: lines.append(self.build_statusbar()) if lines: - self.mainframe.set_footer(urwid.Pile(lines)) + mainframe.set_footer(urwid.Pile(lines)) else: - self.mainframe.set_footer(None) + mainframe.set_footer(None) # force a screen redraw if self.mainloop.screen.started: self.mainloop.draw_screen() @@ -434,6 +461,7 @@ class UI(object): info['buffer_type'] = btype info['total_messages'] = self.dbman.count_messages('*') info['pending_writes'] = len(self.dbman.writequeue) + info['input_queue'] = ' '.join(self.input_queue) lefttxt = righttxt = u'' if cb is not None: diff --git a/alot/widgets/globals.py b/alot/widgets/globals.py index 210d4b54..940db187 100644 --- a/alot/widgets/globals.py +++ b/alot/widgets/globals.py @@ -57,9 +57,9 @@ class ChoiceWidget(urwid.Text): return True def keypress(self, size, key): - if key == 'select' and self.select is not None: + if key == 'enter' and self.select is not None: self.callback(self.select) - elif key == 'cancel' and self.cancel is not None: + elif key == 'esc' and self.cancel is not None: self.callback(self.cancel) elif key in self.choices: self.callback(self.choices[key]) @@ -114,9 +114,9 @@ class CompleteEdit(urwid.Edit): else: self.historypos = (self.historypos - 1) % len(self.history) self.set_edit_text(self.history[self.historypos]) - elif key == 'select': + elif key == 'enter': self.on_exit(self.edit_text) - elif key == 'cancel': + elif key == 'esc': self.on_exit(None) elif key == 'ctrl a': self.set_edit_pos(0) diff --git a/alot/widgets/utils.py b/alot/widgets/utils.py index b50b2db9..ce64a6eb 100644 --- a/alot/widgets/utils.py +++ b/alot/widgets/utils.py @@ -6,7 +6,6 @@ Utility Widgets not specific to alot """ import urwid -import logging class AttrFlipWidget(urwid.AttrMap): @@ -43,22 +42,3 @@ class DialogBox(urwid.WidgetWrap): def keypress(self, size, key): return self.body.keypress(size, key) - - -class CatchKeyWidgetWrap(urwid.WidgetWrap): - def __init__(self, widget, key, on_catch, relay_rest=True): - urwid.WidgetWrap.__init__(self, widget) - self.key = key - self.relay = relay_rest - self.on_catch = on_catch - - def selectable(self): - return True - - def keypress(self, size, key): - logging.debug('CATCH KEY: %s' % key) - logging.debug('relay: %s' % self.relay) - if key == self.key: - self.on_catch() - elif self._w.selectable() and self.relay: - return self._w.keypress(size, key) diff --git a/docs/source/api/interface.rst b/docs/source/api/interface.rst index d2294e26..7d66cf1f 100644 --- a/docs/source/api/interface.rst +++ b/docs/source/api/interface.rst @@ -38,22 +38,7 @@ input and acts on it: .. module:: alot.ui .. autoclass:: UI - - .. autoattribute:: buffers - .. autoattribute:: current_buffer - .. autoattribute:: dbman - - .. automethod:: apply_command - .. automethod:: prompt - .. automethod:: choice - .. automethod:: notify - .. automethod:: clear_notify - .. automethod:: buffer_open - .. automethod:: buffer_focus - .. automethod:: buffer_close - .. automethod:: get_buffers_of_type - .. automethod:: exit - + :members: Buffers ---------- diff --git a/docs/source/conf.py b/docs/source/conf.py index 039ef980..030c19c3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -242,6 +242,7 @@ latex_documents = [ # If false, no module index is generated. #latex_domain_indices = True +autodoc_member_order = 'groupwise' # -- Options for manual page output -------------------------------------------- |