Commit ef38ff8f authored by mathieui's avatar mathieui

Merge branch 'feature/unique-prefix-tab-names' into 'master'

Unique prefix tab names

Closes #3525

See merge request poezio/poezio!94
parents 1b974d2d 1e7ce437
......@@ -548,6 +548,13 @@ use_bookmarks_method =
# “true” should be the most comfortable value
#lazy_resize = true
# If set to true and if show_tab_names is set, the info bar will only show
# the unique prefix of each tab name instead of the full name. This saves a
# lot of space if many tabs exist or are active.
# Best used with the `/wup` command or the `_go_to_room_name` action to select
# a tab based on the prefix.
#unique_prefix_tab_names = false
# Bindings are keyboard shortcut aliases. You can use them
# to define your own keys and bind them with some functions
......@@ -93,6 +93,15 @@ These commands work in *any* tab.
Go to the matching tab. If the argument is a number, it goes to the tab with that number.
Otherwise, it goes to the next tab whose name contains the given string.
**Usage:** ``/wup <prefix>``
Go to the tab whose name starts with `prefix`. If multiple tabs start
with that prefix, no action is taken.
(Mnemonic: Window by Unique Prefix)
**Usage:** ``/status <availability> [status message]``
......@@ -793,6 +793,17 @@ or the way messages are displayed.
If you want to show the tab name in the bottom Tab bar, set this to ``true``.
**Default value:** ``false``
If this and :term:`show_tab_names` is set to true, only the shortest
unique prefix of each tab name is shown instead of the full name. This
can declutter the interface in an instance with many tabs shown in the
interface, while not having to use numbers (which may change completely due to reordering).
Takes precedence over `use_tab_nicks`.
**Default value:** ``true``
......@@ -374,6 +374,15 @@ Actions list
Similar to F4.
**_go_to_room_name**: Jump to a tab by unique prefix.
Similar to :term:`/wup` and the default *Alt-j*. This action will take
input as long as there is at least one tab name starting with the input
given so far. If there is exactly one tab matching, the action completes
and the current tab is switched over to the tab matching the input. If
no tab matches, the action completes without any change. This means that
you can typically abort the action with Escape.
Status actions
......@@ -17,6 +17,7 @@ import subprocess
import time
import string
import logging
import itertools
from slixmpp import JID, InvalidJID, Message
from poezio.poezio_shlex import shlex
......@@ -468,3 +469,22 @@ def safeJID(*args, **kwargs) -> JID:
return JID('')
def unique_prefix_of(a: str, b: str) -> str:
Return the unique prefix of `a` with `b`.
Corner cases:
* If `a` and `b` share no prefix, the first letter of `a` is returned.
* If `a` and `b` are equal, `a` is returned.
* If `a` is a prefix of `b`, `a` is returned.
* If `b` is a prefix of `a`, `b` plus the first letter of `a` after the
common prefix is returned.
for i, (ca, cb) in enumerate(itertools.zip_longest(a, b)):
if ca != cb:
return a[:i+1]
# both are equal, return a
return a
......@@ -136,6 +136,7 @@ DEFAULT_CONFIG = {
'theme': 'default',
'themes_dir': '',
'tmp_image_dir': '',
'unique_prefix_tab_names': False,
'use_bookmarks_method': '',
'use_log': True,
'use_remote_bookmarks': True,
......@@ -219,6 +219,20 @@ class CommandCore:
def wup(self, args):
/wup <prefix of name>
if args is None:
prefix = args[0]
_, match = self.core.tabs.find_by_unique_prefix(prefix)
if match is None:
def move_tab(self, args):
......@@ -209,6 +209,7 @@ class Core:
'_show_plugins': self.command.plugins,
'_show_xmltab': self.command.xml_tab,
'_toggle_pane': self.toggle_left_pane,
"_go_to_room_name": self.go_to_room_name,
###### status actions ######
'_available': lambda: self.command.status('available'),
'_away': lambda: self.command.status('away'),
......@@ -1108,6 +1109,34 @@ class Core:
keyboard.continuation_keys_callback = read_next_digit
def go_to_room_name(self) -> None:
room_name_jump = []
def read_next_letter(s) -> None:
nonlocal room_name_jump
any_matched, unique_tab = self.tabs.find_by_unique_prefix(
if not any_matched:
if unique_tab is not None:
# NOTE: returning here means that as soon as the tab is
# matched, normal input resumes. If we do *not* return here,
# any further characters matching the prefix of the tab will
# be swallowed (and a lot of tab switching will happen...),
# until a non-matching character or escape or something is
# pressed.
# This behaviour *may* be desirable.
keyboard.continuation_keys_callback = read_next_letter
keyboard.continuation_keys_callback = read_next_letter
def go_to_roster(self) -> None:
"Select the roster as the current tab"
......@@ -1709,6 +1738,12 @@ class Core:
usage='<number or name>',
shortdesc='Go to the specified room',
shortdesc='Go to the tab whose name uniquely starts with prefix',
self.commands['w'] = self.commands['win']
......@@ -24,11 +24,12 @@ have become [0|1|2|3], with the tab "4" renumbered to "3" if gap tabs are
from typing import List, Dict, Type, Optional, Union
from typing import List, Dict, Type, Optional, Union, Tuple
from collections import defaultdict
from slixmpp import JID
from poezio import tabs
from import EventHandler
from poezio.config import config
class Tabs:
......@@ -139,6 +140,37 @@ class Tabs:
return self._tabs[i]
return None
def find_by_unique_prefix(self, prefix: str) -> Tuple[bool, Optional[tabs.Tab]]:
Get a tab by its unique name prefix, ignoring case.
:return: A tuple indicating the presence of any match, as well as the
uniquely matched tab (if any).
The first element, a boolean, in the returned tuple indicates whether
at least one tab matched.
The second element (a Tab) in the returned tuple is the uniquely
matched tab, if any. If multiple or no tabs match the prefix, the
second element in the tuple is :data:`None`.
# TODO: should this maybe use something smarter than .lower()?
# something something stringprep?
prefix = prefix.lower()
candidate = None
any_matched = False
for tab in self._tabs:
if not
any_matched = True
if candidate is not None:
# multiple tabs match -> return None
return True, None
candidate = tab
return any_matched, candidate
def by_name_and_class(self, name: str,
cls: Type[tabs.Tab]) -> Optional[tabs.Tab]:
"""Get a tab with its name and class"""
......@@ -6,6 +6,7 @@ The GlobalInfoBar can be either horizontal or vertical
import logging
import itertools
log = logging.getLogger(__name__)
import curses
......@@ -13,6 +14,7 @@ import curses
from poezio.config import config
from import Win
from poezio.theming import get_theme, to_curses_attr
from poezio.common import unique_prefix_of
class GlobalInfoBar(Win):
......@@ -33,6 +35,34 @@ class GlobalInfoBar(Win):
show_nums = config.get('show_tab_numbers')
use_nicks = config.get('use_tab_nicks')
show_inactive = config.get('show_inactive_tabs')
unique_prefix_tab_names = config.get('unique_prefix_tab_names')
if unique_prefix_tab_names:
unique_prefixes = [None] * len(self.core.tabs)
sorted_tab_indices = sorted(
(str(, i)
for i, tab in enumerate(self.core.tabs)
prev_name = ""
for (name, i), next_item in itertools.zip_longest(
sorted_tab_indices, sorted_tab_indices[1:]):
# TODO: should this maybe use something smarter than .lower()?
# something something stringprep?
name = name.lower()
prefix_prev = unique_prefix_of(name, prev_name)
if next_item is not None:
prefix_next = unique_prefix_of(name, next_item[0].lower())
prefix_next = name[0]
# to be unique, we have to use the longest prefix
if len(prefix_next) > len(prefix_prev):
prefix = prefix_next
prefix = prefix_prev
unique_prefixes[i] = prefix
prev_name = name
for nb, tab in enumerate(self.core.tabs):
if not tab:
......@@ -46,7 +76,9 @@ class GlobalInfoBar(Win):
if show_names:
self.addstr(' ', to_curses_attr(color))
if show_names:
if use_nicks:
if unique_prefix_tab_names:
self.addstr(unique_prefixes[nb], to_curses_attr(color))
elif use_nicks:
self.addstr("%s" % str(tab.get_nick()),
......@@ -11,7 +11,7 @@ from poezio.common import (_datetime_tuple as datetime_tuple, get_utc_time,
get_local_time, shell_split, _find_argument_quoted
as find_argument_quoted, _find_argument_unquoted as
find_argument_unquoted, parse_str_to_secs,
parse_secs_to_str, safeJID)
parse_secs_to_str, safeJID, unique_prefix_of)
def test_utc_time():
delta = timedelta(seconds=-3600)
......@@ -63,3 +63,22 @@ def test_parse_secs_to_str():
def test_safeJID():
assert safeJID('toto@titi/tata') == JID('toto@titi/tata')
assert safeJID('toto@…') == JID('')
def test_unique_prefix_of__no_shared_prefix():
assert unique_prefix_of("a", "b") == "a"
assert unique_prefix_of("foo", "bar") == "f"
assert unique_prefix_of("foo", "") == "f"
def test_unique_prefix_of__equal():
assert unique_prefix_of("foo", "foo") == "foo"
def test_unique_prefix_of__a_prefix():
assert unique_prefix_of("foo", "foobar") == "foo"
def test_unique_prefix_of__b_prefix():
assert unique_prefix_of("foobar", "foo") == "foob"
def test_unique_prefix_of__normal_shared_prefix():
assert unique_prefix_of("foobar", "foobaz") == "foobar"
assert unique_prefix_of("fnord", "funky") == "fn"
assert unique_prefix_of("asbestos", "aspergers") == "asb"
......@@ -183,3 +183,25 @@ def test_slice():
assert tabs[1:2][0] is dummy2
def test_find_by_unique_prefix():
tabs = Tabs(h)
t1 = DummyTab()
t2 = DummyTab()
t3 = DummyTab()
tabs.append(t3) = "foo" = "bar" = "fnord"
assert tabs.find_by_unique_prefix("f") == (True, None)
assert tabs.find_by_unique_prefix("b") == (True, t2)
assert tabs.find_by_unique_prefix("fo") == (True, t1)
assert tabs.find_by_unique_prefix("fn") == (True, t3)
assert tabs.find_by_unique_prefix("fx") == (False, None)
assert tabs.find_by_unique_prefix("x") == (False, None)
assert tabs.find_by_unique_prefix("") == (True, None)
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment