basetabs.py 37.7 KB
Newer Older
1
"""
2 3 4 5 6 7 8 9 10 11 12 13
Module for the base Tabs

The root class Tab defines the generic interface and attributes of a
tab. A tab organizes various Windows around the screen depending
of the tab specificity. If the tab shows messages, it will also
reference a buffer containing the messages.

Each subclass should redefine its own refresh() and resize() method
according to its windows.

This module also defines ChatTabs, the parent class for all tabs
revolving around chats.
14 15
"""

16 17
from __future__ import annotations

18
import copy
19 20
import logging
import string
Madhur Garg's avatar
Madhur Garg committed
21
import asyncio
22
import time
Maxime Buquet's avatar
Maxime Buquet committed
23
from math import ceil, log10
Madhur Garg's avatar
Madhur Garg committed
24
from datetime import datetime
25
from xml.etree import ElementTree as ET
mathieui's avatar
mathieui committed
26 27 28 29 30 31 32
from typing import (
    Any,
    Callable,
    Dict,
    List,
    Optional,
    Union,
33
    Tuple,
mathieui's avatar
mathieui committed
34 35
    TYPE_CHECKING,
)
mathieui's avatar
mathieui committed
36

37 38 39 40 41 42
from poezio import (
    poopt,
    timed_events,
    xhtml,
    windows
)
43
from poezio.core.structs import Command, Completion, Status
44 45
from poezio.common import safeJID
from poezio.config import config
Maxime Buquet's avatar
Maxime Buquet committed
46
from poezio.decorators import command_args_parser, refresh_wrapper
47 48
from poezio.logger import logger
from poezio.text_buffer import TextBuffer
Maxime Buquet's avatar
Maxime Buquet committed
49
from poezio.theming import get_theme, dump_tuple
50
from poezio.ui.funcs import truncate_nick
mathieui's avatar
mathieui committed
51
from poezio.ui.types import BaseMessage, InfoMessage, Message
Maxime Buquet's avatar
Maxime Buquet committed
52

53
from slixmpp import JID, InvalidJID, Message as SMessage
54

mathieui's avatar
mathieui committed
55
if TYPE_CHECKING:
56
    from _curses import _CursesWindow  # pylint: disable=E0611
57 58
    from poezio.size_manager import SizeManager
    from poezio.core.core import Core
mathieui's avatar
mathieui committed
59

mathieui's avatar
mathieui committed
60 61
log = logging.getLogger(__name__)

62
# getters for tab colors (lambdas, so that they are dynamic)
63
STATE_COLORS = {
mathieui's avatar
mathieui committed
64 65 66 67 68 69 70 71 72 73 74 75
    'disconnected': lambda: get_theme().COLOR_TAB_DISCONNECTED,
    'scrolled': lambda: get_theme().COLOR_TAB_SCROLLED,
    'nonempty': lambda: get_theme().COLOR_TAB_NONEMPTY,
    'joined': lambda: get_theme().COLOR_TAB_JOINED,
    'message': lambda: get_theme().COLOR_TAB_NEW_MESSAGE,
    'composing': lambda: get_theme().COLOR_TAB_COMPOSING,
    'highlight': lambda: get_theme().COLOR_TAB_HIGHLIGHT,
    'private': lambda: get_theme().COLOR_TAB_PRIVATE,
    'normal': lambda: get_theme().COLOR_TAB_NORMAL,
    'current': lambda: get_theme().COLOR_TAB_CURRENT,
    'attention': lambda: get_theme().COLOR_TAB_ATTENTION,
}
76
VERTICAL_STATE_COLORS = {
mathieui's avatar
mathieui committed
77 78 79 80 81 82 83 84 85 86 87 88
    'disconnected': lambda: get_theme().COLOR_VERTICAL_TAB_DISCONNECTED,
    'scrolled': lambda: get_theme().COLOR_VERTICAL_TAB_SCROLLED,
    'nonempty': lambda: get_theme().COLOR_VERTICAL_TAB_NONEMPTY,
    'joined': lambda: get_theme().COLOR_VERTICAL_TAB_JOINED,
    'message': lambda: get_theme().COLOR_VERTICAL_TAB_NEW_MESSAGE,
    'composing': lambda: get_theme().COLOR_VERTICAL_TAB_COMPOSING,
    'highlight': lambda: get_theme().COLOR_VERTICAL_TAB_HIGHLIGHT,
    'private': lambda: get_theme().COLOR_VERTICAL_TAB_PRIVATE,
    'normal': lambda: get_theme().COLOR_VERTICAL_TAB_NORMAL,
    'current': lambda: get_theme().COLOR_VERTICAL_TAB_CURRENT,
    'attention': lambda: get_theme().COLOR_VERTICAL_TAB_ATTENTION,
}
89

90 91
# priority of the different tab states when using Alt+e
# higher means more priority, < 0 means not selectable
92
STATE_PRIORITY = {
mathieui's avatar
mathieui committed
93 94 95 96 97 98 99 100 101 102 103 104
    'normal': -1,
    'current': -1,
    'disconnected': 0,
    'nonempty': 0.1,
    'scrolled': 0.5,
    'joined': 0.8,
    'composing': 0.9,
    'message': 1,
    'highlight': 2,
    'private': 2,
    'attention': 3
}
105

106
SHOW_NAME = {
mathieui's avatar
mathieui committed
107 108 109 110 111 112 113
    'dnd': 'busy',
    'away': 'away',
    'xa': 'not available',
    'chat': 'chatty',
    '': 'available'
}

114

115
class Tab:
116 117
    plugin_commands: Dict[str, Command] = {}
    plugin_keys: Dict[str, Callable] = {}
118 119 120
    # Placeholder values, set on resize
    height = 1
    width = 1
mathieui's avatar
mathieui committed
121

122
    def __init__(self, core: Core):
123
        self.core = core
124
        self.nb = 0
125 126
        if not hasattr(self, 'name'):
            self.name = self.__class__.__name__
127
        self.input = None
mathieui's avatar
mathieui committed
128
        self.closed = False
129
        self._state = 'normal'
130
        self._prev_state = None
131

132
        self.need_resize = False
mathieui's avatar
mathieui committed
133 134 135
        self.key_func = {}  # each tab should add their keys in there
        # and use them in on_input
        self.commands = {}  # and their own commands
136

137
    @property
138
    def size(self) -> SizeManager:
139
        return self.core.size
140

141
    @staticmethod
mathieui's avatar
mathieui committed
142
    def tab_win_height() -> int:
143 144 145 146
        """
        Returns 1 or 0, depending on if we are using the vertical tab list
        or not.
        """
147
        if config.get('enable_vertical_tab_list'):
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163
            return 0
        return 1

    @property
    def info_win(self):
        return self.core.information_win

    @property
    def color(self):
        return STATE_COLORS[self._state]()

    @property
    def vertical_color(self):
        return VERTICAL_STATE_COLORS[self._state]()

    @property
mathieui's avatar
mathieui committed
164
    def state(self) -> str:
165 166 167
        return self._state

    @state.setter
mathieui's avatar
mathieui committed
168
    def state(self, value: str):
169
        if value not in STATE_COLORS:
170 171 172 173
            log.debug("Invalid value for tab state: %s", value)
        elif STATE_PRIORITY[value] < STATE_PRIORITY[self._state] and \
                value not in ('current', 'disconnected') and \
                not (self._state == 'scrolled' and value == 'disconnected'):
mathieui's avatar
mathieui committed
174 175 176 177 178 179 180
            log.debug(
                "Did not set state because of lower priority, asked: %s, kept: %s",
                value, self._state)
        elif self._state == 'disconnected' and value not in ('joined',
                                                             'current'):
            log.debug(
                'Did not set state because disconnected tabs remain visible')
181 182
        else:
            self._state = value
183 184 185
            if self._state == 'current':
                self._prev_state = None

mathieui's avatar
mathieui committed
186
    def set_state(self, value: str):
187 188
        self._state = value

189 190 191 192 193 194 195 196 197 198
    def save_state(self):
        if self._state != 'composing':
            self._prev_state = self._state

    def restore_state(self):
        if self.state == 'composing' and self._prev_state:
            self._state = self._prev_state
            self._prev_state = None
        elif not self._prev_state:
            self._state = 'normal'
199 200

    @staticmethod
201
    def initial_resize(scr: _CursesWindow):
202
        Tab.height, Tab.width = scr.getmaxyx()
203
        windows.base_wins.TAB_WIN = scr
204

205 206 207 208 209 210 211 212
    def missing_command_callback(self, command_name):
        """
        Callback executed when a command is not found.
        Returns True if the callback took care of displaying
        the error message, False otherwise.
        """
        return False

mathieui's avatar
mathieui committed
213
    def register_commands_batch(self, commands: List[Dict[str, Any]]):
214 215 216 217 218 219 220 221 222 223
        """
        Add several commands in a row, using a list of dictionaries
        """
        for command in commands:
            name = command['name']
            func = command['func']
            desc = command.get('desc', '')
            shortdesc = command.get('shortdesc', '')
            completion = command.get('completion')
            usage = command.get('usage', '')
mathieui's avatar
mathieui committed
224 225 226 227 228 229 230 231 232
            self.register_command(
                name,
                func,
                desc=desc,
                shortdesc=shortdesc,
                completion=completion,
                usage=usage)

    def register_command(self,
mathieui's avatar
mathieui committed
233 234
                         name: str,
                         func: Callable,
mathieui's avatar
mathieui committed
235 236 237
                         *,
                         desc='',
                         shortdesc='',
mathieui's avatar
mathieui committed
238
                         completion: Optional[Callable] = None,
mathieui's avatar
mathieui committed
239
                         usage=''):
240 241 242 243 244 245 246
        """
        Add a command
        """
        if name in self.commands:
            return
        if not desc and shortdesc:
            desc = shortdesc
247
        self.commands[name] = Command(func, desc, completion, shortdesc, usage)
248

mathieui's avatar
mathieui committed
249
    def complete_commands(self, the_input: windows.Input) -> bool:
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266
        """
        Does command completion on the specified input for both global and tab-specific
        commands.
        This should be called from the completion method (on tab, for example), passing
        the input where completion is to be made.
        It can completion the command name itself or an argument of the command.
        Returns True if a completion was made, False else.
        """
        txt = the_input.get_text()
        # check if this is a command
        if txt.startswith('/') and not txt.startswith('//'):
            position = the_input.get_argument_position(quoted=False)
            if position == 0:
                words = ['/%s'% (name) for name in sorted(self.core.commands)] +\
                    ['/%s' % (name) for name in sorted(self.commands)]
                the_input.new_completion(words, 0)
                # Do not try to cycle command completion if there was only
267
                # one possibility. The next tab will complete the argument.
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282
                # Otherwise we would need to add a useless space before being
                # able to complete the arguments.
                hit_copy = set(the_input.hit_list)
                if len(hit_copy) == 1:
                    the_input.do_command(' ')
                    the_input.reset_completion()
                return True
            # check if we are in the middle of the command name
            elif len(txt.split()) > 1 or\
                    (txt.endswith(' ') and not the_input.last_completion):
                command_name = txt.split()[0][1:]
                if command_name in self.commands:
                    command = self.commands[command_name]
                elif command_name in self.core.commands:
                    command = self.core.commands[command_name]
mathieui's avatar
mathieui committed
283
                else:  # Unknown command, cannot complete
284
                    return False
285
                if command.comp is None:
mathieui's avatar
mathieui committed
286
                    return False  # There's no completion function
mathieui's avatar
mathieui committed
287 288 289 290
                comp = command.comp(the_input)
                if comp:
                    return comp.run()
                return comp
291 292
        return False

mathieui's avatar
mathieui committed
293
    def execute_command(self, provided_text: str) -> bool:
294 295 296 297 298 299 300 301
        """
        Execute the command in the input and return False if
        the input didn't contain a command
        """
        txt = provided_text or self.input.key_enter()
        if txt.startswith('/') and not txt.startswith('//') and\
                not txt.startswith('/me '):
            command = txt.strip().split()[0][1:]
mathieui's avatar
mathieui committed
302
            arg = txt[2 + len(command):]  # jump the '/' and the ' '
303
            func = None
mathieui's avatar
mathieui committed
304
            if command in self.commands:  # check tab-specific commands
305
                func = self.commands[command].func
mathieui's avatar
mathieui committed
306
            elif command in self.core.commands:  # check global commands
307
                func = self.core.commands[command].func
308 309 310
            else:
                low = command.lower()
                if low in self.commands:
311
                    func = self.commands[low].func
312
                elif low in self.core.commands:
313
                    func = self.core.commands[low].func
314
                else:
315 316 317
                    if self.missing_command_callback is not None:
                        error_handled = self.missing_command_callback(low)
                    if not error_handled:
mathieui's avatar
mathieui committed
318 319
                        self.core.information(
                            "Unknown command (%s)" % (command), 'Error')
mathieui's avatar
mathieui committed
320
            if command in ('correct', 'say'):  # hack
321 322 323 324
                arg = xhtml.convert_simple_to_full_colors(arg)
            else:
                arg = xhtml.clean_text_simple(arg)
            if func:
325 326
                if hasattr(self.input, "reset_completion"):
                    self.input.reset_completion()
327 328 329 330
                if asyncio.iscoroutinefunction(func):
                    asyncio.ensure_future(func(arg))
                else:
                    func(arg)
331 332 333 334
            return True
        else:
            return False

335
    def refresh_tab_win(self) -> None:
336
        if config.get('enable_vertical_tab_list'):
337 338 339
            left_tab_win = self.core.left_tab_win
            if left_tab_win and not self.size.core_degrade_x:
                left_tab_win.refresh()
340
        elif not self.size.core_degrade_y:
341
            self.core.tab_win.refresh()
342

343 344 345 346 347 348
    def refresh_input(self):
        """Refresh the current input if any"""
        if self.input is not None:
            self.input.refresh()
            self.core.doupdate()

349 350 351 352 353 354 355 356 357 358
    def refresh(self):
        """
        Called on each screen refresh (when something has changed)
        """
        pass

    def get_name(self):
        """
        get the name of the tab
        """
359
        return self.name
360

mathieui's avatar
mathieui committed
361
    def get_nick(self) -> str:
362 363 364
        """
        Get the nick of the tab (defaults to its name)
        """
365
        return self.name
366

mathieui's avatar
mathieui committed
367
    def get_text_window(self) -> Optional[windows.TextWin]:
368 369 370 371 372
        """
        Returns the principal TextWin window, if there's one
        """
        return None

mathieui's avatar
mathieui committed
373
    def on_input(self, key: str, raw: bool):
374 375 376 377 378
        """
        raw indicates if the key should activate the associated command or not.
        """
        pass

379
    def update_commands(self) -> None:
380
        for c in self.plugin_commands:
381
            if c not in self.commands:
382 383
                self.commands[c] = self.plugin_commands[c]

384
    def update_keys(self) -> None:
385
        for k in self.plugin_keys:
386
            if k not in self.key_func:
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438
                self.key_func[k] = self.plugin_keys[k]

    def on_lose_focus(self):
        """
        called when this tab loses the focus.
        """
        self.state = 'normal'

    def on_gain_focus(self):
        """
        called when this tab gains the focus.
        """
        self.state = 'current'

    def on_scroll_down(self):
        """
        Defines what happens when we scroll down
        """
        pass

    def on_scroll_up(self):
        """
        Defines what happens when we scroll up
        """
        pass

    def on_line_up(self):
        """
        Defines what happens when we scroll one line up
        """
        pass

    def on_line_down(self):
        """
        Defines what happens when we scroll one line up
        """
        pass

    def on_half_scroll_down(self):
        """
        Defines what happens when we scroll half a screen down
        """
        pass

    def on_half_scroll_up(self):
        """
        Defines what happens when we scroll half a screen up
        """
        pass

    def on_info_win_size_changed(self):
        """
439
        Called when the window with the information is resized
440 441 442
        """
        pass

443
    def on_close(self) -> None:
444 445 446 447 448
        """
        Called when the tab is to be closed
        """
        if self.input:
            self.input.on_delete()
mathieui's avatar
mathieui committed
449
        self.closed = True
450

451
    def matching_names(self) -> List[Tuple[int, str]]:
452 453 454 455 456 457 458 459 460 461 462 463
        """
        Returns a list of strings that are used to name a tab with the /win
        command.  For example you could switch to a tab that returns
        ['hello', 'coucou'] using /win hel, or /win coucou
        If not implemented in the tab, it just doesn’t match with anything.
        """
        return []

    def __del__(self):
        log.debug('------ Closing tab %s', self.__class__.__name__)


mathieui's avatar
mathieui committed
464
class GapTab(Tab):
465 466 467 468 469 470
    def __bool__(self):
        return False

    def __len__(self):
        return 0

471 472
    @property
    def name(self):
473 474 475
        return ''

    def refresh(self):
mathieui's avatar
mathieui committed
476 477 478
        log.debug(
            'WARNING: refresh() called on a gap tab, this should not happen')

479 480 481 482 483 484 485 486

class ChatTab(Tab):
    """
    A tab containing a chat of any type.
    Just use this class instead of Tab if the tab needs a recent-words completion
    Also, ^M is already bound to on_enter
    And also, add the /say command
    """
487 488
    plugin_commands: Dict[str, Command] = {}
    plugin_keys: Dict[str, Callable] = {}
mathieui's avatar
mathieui committed
489
    message_type = 'chat'
mathieui's avatar
mathieui committed
490

491
    def __init__(self, core, jid: Union[JID, str]):
492
        Tab.__init__(self, core)
493 494 495 496 497

        if not isinstance(jid, JID):
            jid = JID(jid)
        assert jid.domain
        self._jid = jid
498
        #: Is the tab currently requesting MAM data?
499
        self.query_status = False
500
        self._name: Optional[str] = jid.full
501
        self.text_win = windows.TextWin()
mathieui's avatar
mathieui committed
502
        self.directed_presence = None
503
        self._text_buffer = TextBuffer()
504
        self._text_buffer.add_window(self.text_win)
mathieui's avatar
mathieui committed
505
        self.chatstate = None  # can be "active", "composing", "paused", "gone", "inactive"
louiz’'s avatar
louiz’ committed
506
        # We keep a reference of the event that will set our chatstate to "paused", so that
507 508
        # we can delete it or change it if we need to
        self.timed_event_paused = None
509
        self.timed_event_not_paused = None
510
        # Keeps the last sent message to complete it easily in completion_correct, and to replace it.
mathieui's avatar
mathieui committed
511
        self.last_sent_message = {}
512 513 514 515
        self.key_func['M-v'] = self.move_separator
        self.key_func['M-h'] = self.scroll_separator
        self.key_func['M-/'] = self.last_words_completion
        self.key_func['^M'] = self.on_enter
mathieui's avatar
mathieui committed
516 517 518 519 520
        self.register_command(
            'say',
            self.command_say,
            usage='<message>',
            shortdesc='Send the message.')
Madhur Garg's avatar
Madhur Garg committed
521
        self.register_command(
522
            'scrollback',
Maxime Buquet's avatar
Maxime Buquet committed
523
            self.command_scrollback,
524
            usage="end home clear status goto <+|-linecount>|<linenum>|<timestamp>",
525
            shortdesc='Scrollback to the given line number, message, or clear the buffer.')
526
        self.commands['sb'] = self.commands['scrollback']
mathieui's avatar
mathieui committed
527 528 529 530 531 532 533 534 535 536 537 538 539
        self.register_command(
            'xhtml',
            self.command_xhtml,
            usage='<custom xhtml>',
            shortdesc='Send custom XHTML.')
        self.register_command(
            'clear', self.command_clear, shortdesc='Clear the current buffer.')
        self.register_command(
            'correct',
            self.command_correct,
            desc='Fix the last message with whatever you want.',
            shortdesc='Correct the last message.',
            completion=self.completion_correct)
540
        self.chat_state: Optional[str] = None
541 542 543
        self.update_commands()
        self.update_keys()

544 545
    @property
    def name(self) -> str:
546 547 548
        if self._name is not None:
            return self._name
        return self._jid.full
549 550 551 552

    @name.setter
    def name(self, value: Union[JID, str]) -> None:
        if isinstance(value, JID):
553
            self.jid = value
554 555 556 557
        elif isinstance(value, str):
            try:
                value = JID(value)
                if value.domain:
558
                    self._jid = value
559
            except InvalidJID:
560
                self._name = value
561
        else:
562
            raise TypeError("Name %r must be of type JID or str." % value)
563 564

    @property
565
    def jid(self) -> JID:
566
        return copy.copy(self._jid)
567 568

    @jid.setter
569
    def jid(self, value: JID) -> None:
570
        if not isinstance(value, JID):
571 572
            raise TypeError("Jid %r must be of type JID." % value)
        assert value.domain
573
        self._jid = value
574

mathieui's avatar
mathieui committed
575
    @property
mathieui's avatar
mathieui committed
576
    def general_jid(self) -> JID:
577
        raise NotImplementedError
mathieui's avatar
mathieui committed
578

579
    def log_message(self, message: BaseMessage, typ=1):
580 581 582
        """
        Log the messages in the archives.
        """
583
        name = self.jid.bare
584 585 586
        if not isinstance(message, Message):
            return
        if not logger.log_message(name, message.nickname, message.txt, date=message.time, typ=typ):
587
            self.core.information('Unable to write in the log file', 'Error')
588

589 590 591
    def add_message(self, message: BaseMessage, typ=1):
        self.log_message(message, typ=typ)
        self._text_buffer.add_message(message)
mathieui's avatar
mathieui committed
592 593 594 595 596 597 598 599 600

    def modify_message(self,
                       txt,
                       old_id,
                       new_id,
                       user=None,
                       jid=None,
                       nickname=None):
        message = self._text_buffer.modify_message(
601
            txt, old_id, new_id, user=user, jid=jid)
602
        if message:
603
            self.log_message(message, typ=1)
Maxime Buquet's avatar
Maxime Buquet committed
604
            self.text_win.modify_message(message.identifier, message)
605 606 607 608 609 610 611 612 613
            self.core.refresh_window()
            return True
        return False

    def last_words_completion(self):
        """
        Complete the input with words recently said
        """
        # build the list of the recent words
mathieui's avatar
mathieui committed
614
        char_we_dont_want = string.punctuation + ' ’„“”…«»'
615
        words = []
616 617 618 619 620 621 622 623 624
        for msg in self._text_buffer.messages[:-40:-1]:
            if not msg:
                continue
            txt = xhtml.clean_text(msg.txt)
            for char in char_we_dont_want:
                txt = txt.replace(char, ' ')
            for word in txt.split():
                if len(word) >= 4 and word not in words:
                    words.append(word)
625
        words.extend([word for word in config.get('words').split(':') if word])
626 627 628 629 630 631 632 633 634 635 636
        self.input.auto_completion(words, ' ', quotify=False)

    def on_enter(self):
        txt = self.input.key_enter()
        if txt:
            if not self.execute_command(txt):
                if txt.startswith('//'):
                    txt = txt[1:]
                self.command_say(xhtml.convert_simple_to_full_colors(txt))
        self.cancel_paused_delay()

637 638
    @command_args_parser.raw
    def command_xhtml(self, xhtml):
639 640 641
        """"
        /xhtml <custom xhtml>
        """
642
        message = self.generate_xhtml_message(xhtml)
643 644 645
        if message:
            message.send()

646
    def generate_xhtml_message(self, arg: str) -> SMessage:
647 648 649
        if not arg:
            return
        try:
mathieui's avatar
mathieui committed
650 651
            body = xhtml.clean_text(
                xhtml.xhtml_to_poezio_colors(arg, force=True))
652
            ET.fromstring(arg)
653
        except xml.sax._exceptions.SAXParseException:
654
            self.core.information('Could not send custom xhtml', 'Error')
655
            log.error('/xhtml: Unable to send custom xhtml')
656 657 658 659 660 661 662 663
            return

        msg = self.core.xmpp.make_message(self.get_dest_jid())
        msg['body'] = body
        msg.enable('html')
        msg['html']['body'] = arg
        return msg

mathieui's avatar
mathieui committed
664
    def get_dest_jid(self) -> JID:
Maxime Buquet's avatar
Maxime Buquet committed
665
        return self.jid
666 667

    @refresh_wrapper.always
668
    def command_clear(self, ignored):
669 670 671 672 673 674
        """
        /clear
        """
        self._text_buffer.messages = []
        self.text_win.rebuild_everything(self._text_buffer)

675
    def check_send_chat_state(self) -> bool:
mathieui's avatar
mathieui committed
676 677 678
        "If we should send a chat state"
        return True

679
    def send_chat_state(self, state: str, always_send: bool = False) -> None:
680 681 682
        """
        Send an empty chatstate message
        """
683 684
        from poezio.tabs import PrivateTab

mathieui's avatar
mathieui committed
685
        if self.check_send_chat_state():
mathieui's avatar
mathieui committed
686 687
            if state in ('active', 'inactive',
                         'gone') and self.inactive and not always_send:
688
                return
689
            if config.get_by_tabname('send_chat_states', self.general_jid):
690 691 692 693
                msg = self.core.xmpp.make_message(self.get_dest_jid())
                msg['type'] = self.message_type
                msg['chat_state'] = state
                self.chat_state = state
694
                msg['no-store'] = True
695
                if isinstance(self, PrivateTab):
696
                    msg.enable('muc')
697 698
                msg.send()

699
    def send_composing_chat_state(self, empty_after: bool) -> None:
700 701 702 703 704
        """
        Send the "active" or "composing" chatstate, depending
        on the the current status of the input
        """
        name = self.general_jid
705
        if config.get_by_tabname('send_chat_states', name):
706 707 708 709 710 711 712 713 714 715 716 717 718 719
            needed = 'inactive' if self.inactive else 'active'
            self.cancel_paused_delay()
            if not empty_after:
                if self.chat_state != "composing":
                    self.send_chat_state("composing")
                self.set_paused_delay(True)
            elif empty_after and self.chat_state != needed:
                self.send_chat_state(needed, True)

    def set_paused_delay(self, composing):
        """
        we create a timed event that will put us to paused
        in a few seconds
        """
720
        if not config.get_by_tabname('send_chat_states', self.general_jid):
721
            return
722 723 724
        # First, cancel the delay if it already exists, before rescheduling
        # it at a new date
        self.cancel_paused_delay()
mathieui's avatar
mathieui committed
725 726
        new_event = timed_events.DelayedEvent(4, self.send_chat_state,
                                              'paused')
727
        self.core.add_timed_event(new_event)
728
        self.timed_event_paused = new_event
mathieui's avatar
mathieui committed
729 730 731
        new_event = timed_events.DelayedEvent(
            30, self.send_chat_state, 'inactive'
            if self.inactive else 'active')
732
        self.core.add_timed_event(new_event)
733
        self.timed_event_not_paused = new_event
734

735
    def cancel_paused_delay(self) -> None:
736 737 738 739 740
        """
        Remove that event from the list and set it to None.
        Called for example when the input is emptied, or when the message
        is sent
        """
741 742 743
        if self.timed_event_paused is not None:
            self.core.remove_timed_event(self.timed_event_paused)
            self.timed_event_paused = None
744 745
            self.core.remove_timed_event(self.timed_event_not_paused)
            self.timed_event_not_paused = None
746

747
    def set_last_sent_message(self, msg: SMessage, correct: bool = False) -> None:
Maxime Buquet's avatar
Maxime Buquet committed
748 749 750 751 752 753 754 755
        """Ensure last_sent_message is set with the correct attributes"""
        if correct:
            # XXX: Is the copy needed. Is the object passed here reused
            # afterwards? Who knows.
            msg = copy.copy(msg)
            msg['id'] = self.last_sent_message['id']
        self.last_sent_message = msg

756
    @command_args_parser.raw
757
    def command_correct(self, line: str) -> None:
758 759 760 761
        """
        /correct <fixed message>
        """
        if not line:
762
            self.core.command.help('correct')
763 764
            return
        if not self.last_sent_message:
765
            self.core.information('There is no message to correct.', 'Error')
766 767 768 769 770
            return
        self.command_say(line, correct=True)

    def completion_correct(self, the_input):
        if self.last_sent_message and the_input.get_argument_position() == 1:
mathieui's avatar
mathieui committed
771 772 773 774
            return Completion(
                the_input.auto_completion, [self.last_sent_message['body']],
                '',
                quotify=False)
775
        return True
776 777

    @property
mathieui's avatar
mathieui committed
778
    def inactive(self) -> bool:
779 780 781 782
        """Whether we should send inactive or active as a chatstate"""
        return self.core.status.show in ('xa', 'away') or\
                (hasattr(self, 'directed_presence') and not self.directed_presence)

783
    def move_separator(self) -> None:
784 785 786 787 788 789 790 791
        self.text_win.remove_line_separator()
        self.text_win.add_line_separator(self._text_buffer)
        self.text_win.refresh()
        self.input.refresh()

    def get_conversation_messages(self):
        return self._text_buffer.messages

792
    def check_scrolled(self) -> None:
793 794 795
        if self.text_win.pos != 0:
            self.state = 'scrolled'

796
    @command_args_parser.raw
797 798 799
    def command_say(self, line, correct=False):
        pass

800 801 802 803
    def goto_build_lines(self, new_date):
        text_buffer = self._text_buffer
        built_lines = []
        message_count = 0
804 805
        timestamp = config.get('show_timestamps')
        nick_size = config.get('max_nick_length')
806
        theme = get_theme()
807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824
        for message in text_buffer.messages:
            # Build lines of a message
            txt = message.txt
            nick = truncate_nick(message.nickname, nick_size)
            offset = 0
            theme = get_theme()
            if message.ack:
                if message.ack > 0:
                    offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
                else:
                    offset += poopt.wcswidth(theme.CHAR_NACK) + 1
            if nick:
                offset += poopt.wcswidth(nick) + 2
            if message.revisions > 0:
                offset += ceil(log10(message.revisions + 1))
            if message.me:
                offset += 1
            if timestamp:
825
                if message.history:
826
                    offset += 1 + theme.LONG_TIME_FORMAT_LENGTH
827 828 829 830 831 832 833 834 835 836 837 838 839 840 841
            lines = poopt.cut_text(txt, self.text_win.width - offset - 1)
            for line in lines:
                built_lines.append(line)
            # Find the message with timestamp less than or equal to the queried
            # timestamp and goto that location in the tab.
            if message.time <= new_date:
                message_count += 1
                if len(self.text_win.built_lines) - self.text_win.height >= len(built_lines):
                    self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - len(built_lines) + 1
                else:
                    self.text_win.pos = 0
        if message_count == 0:
            self.text_win.scroll_up(len(self.text_win.built_lines))
        self.core.refresh_window()

Madhur Garg's avatar
Madhur Garg committed
842
    @command_args_parser.quoted(0, 2)
Maxime Buquet's avatar
Maxime Buquet committed
843
    def command_scrollback(self, args):
Madhur Garg's avatar
Madhur Garg committed
844
        """
845 846 847 848 849
        /sb clear
        /sb home
        /sb end
        /sb goto <+|-linecount>|<linenum>|<timestamp>
        The format of timestamp must be ‘[dd[.mm]-<days ago>] hh:mi[:ss]’
Madhur Garg's avatar
Madhur Garg committed
850 851
        """
        if args is None or len(args) == 0:
Madhur Garg's avatar
Madhur Garg committed
852 853
            args = ['end']
        if len(args) == 1:
Madhur Garg's avatar
Madhur Garg committed
854 855 856
            if args[0] == 'end':
                self.text_win.scroll_down(len(self.text_win.built_lines))
                self.core.refresh_window()
857
                return
Madhur Garg's avatar
Madhur Garg committed
858 859 860
            elif args[0] == 'home':
                self.text_win.scroll_up(len(self.text_win.built_lines))
                self.core.refresh_window()
861
                return
Madhur Garg's avatar
Madhur Garg committed
862 863 864 865
            elif args[0] == 'clear':
                self._text_buffer.messages = []
                self.text_win.rebuild_everything(self._text_buffer)
                self.core.refresh_window()
866
                return
Madhur Garg's avatar
Madhur Garg committed
867
            elif args[0] == 'status':
868
                self.core.information('Total %s lines in this tab.' % len(self.text_win.built_lines), 'Info')
869
                return
Madhur Garg's avatar
Madhur Garg committed
870
        elif len(args) == 2 and args[0] == 'goto':
871 872 873
            for fmt in ('%d %H:%M', '%d %H:%M:%S', '%d:%m %H:%M', '%d:%m %H:%M:%S', '%H:%M', '%H:%M:%S'):
                try:
                    new_date = datetime.strptime(args[1], fmt)
874
                    if 'd' in fmt and 'm' in fmt:
875 876 877 878 879 880 881
                        new_date = new_date.replace(year=datetime.now().year)
                    elif 'd' in fmt:
                        new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month)
                    else:
                        new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month, day=datetime.now().day)
                except ValueError:
                    pass
882
            if args[1].startswith('-'):
883
                # Check if the user is giving argument of type goto <-linecount> or goto [-<days ago>] hh:mi[:ss]
884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901
                if ' ' in args[1]:
                    new_args = args[1].split(' ')
                    new_args[0] = new_args[0].strip('-')
                    new_date = datetime.now()
                    if new_args[0].isdigit():
                        new_date = new_date.replace(day=new_date.day - int(new_args[0]))
                    for fmt in ('%H:%M', '%H:%M:%S'):
                        try:
                            arg_date = datetime.strptime(new_args[1], fmt)
                            new_date = new_date.replace(hour=arg_date.hour, minute=arg_date.minute, second=arg_date.second)
                        except ValueError:
                            pass
                else:
                    scroll_len = args[1].strip('-')
                    if scroll_len.isdigit():
                        self.text_win.scroll_down(int(scroll_len))
                        self.core.refresh_window()
                        return
902
            elif args[1].startswith('+'):
903 904
                scroll_len = args[1].strip('+')
                if scroll_len.isdigit():
Madhur Garg's avatar
Madhur Garg committed
905 906 907
                    self.text_win.scroll_up(int(scroll_len))
                    self.core.refresh_window()
                    return
908
            # Check for the argument of type goto <linenum>
909 910 911
            elif args[1].isdigit():
                if len(self.text_win.built_lines) - self.text_win.height >= int(args[1]):
                    self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - int(args[1])
Madhur Garg's avatar
Madhur Garg committed
912 913
                    self.core.refresh_window()
                    return
Madhur Garg's avatar
Madhur Garg committed
914 915 916 917 918 919
                else:
                    self.text_win.pos = 0
                    self.core.refresh_window()
                    return
            elif args[1] == '0':
                args = ['home']
920 921
            # new_date is the timestamp for which the user has queried.
            self.goto_build_lines(new_date)
Madhur Garg's avatar
Madhur Garg committed
922

923 924 925 926 927 928 929
    def on_line_up(self):
        return self.text_win.scroll_up(1)

    def on_line_down(self):
        return self.text_win.scroll_down(1)

    def on_scroll_up(self):
930
        if not self.query_status:
mathieui's avatar
mathieui committed
931 932
            from poezio import mam
            mam.schedule_scroll_up(tab=self)
933
        return self.text_win.scroll_up(self.text_win.height - 1)
934 935

    def on_scroll_down(self):
mathieui's avatar
mathieui committed
936
        return self.text_win.scroll_down(self.text_win.height - 1)
937 938

    def on_half_scroll_up(self):
mathieui's avatar
mathieui committed
939
        return self.text_win.scroll_up((self.text_win.height - 1) // 2)
940 941

    def on_half_scroll_down(self):
mathieui's avatar
mathieui committed
942
        return self.text_win.scroll_down((self.text_win.height - 1) // 2)
943 944 945 946 947

    @refresh_wrapper.always
    def scroll_separator(self):
        self.text_win.scroll_to_separator()

948

mathieui's avatar
mathieui committed
949
class OneToOneTab(ChatTab):
950
    def __init__(self, core, jid):
951
        ChatTab.__init__(self, core, jid)
952

953 954 955
        self.__status = Status("", "")
        self.last_remote_message = datetime.now()

956 957
        # Set to true once the first disco is done
        self.__initial_disco = False
958
        self.check_features()
mathieui's avatar
mathieui committed
959 960 961 962
        self.register_command(
            'unquery', self.command_unquery, shortdesc='Close the tab.')
        self.register_command(
            'close', self.command_unquery, shortdesc='Close the tab.')
963
        self.register_command(
mathieui's avatar
mathieui committed
964 965
            'attention',
            self.command_attention,
966 967 968
            usage='[message]',
            shortdesc='Request the attention.',
            desc='Attention: Request the attention of the contact.  Can also '
mathieui's avatar
mathieui committed
969
            'send a message along with the attention.')
970

971 972 973 974 975
    def remote_user_color(self):
        return dump_tuple(get_theme().COLOR_REMOTE_USER)

    def update_status(self, status):
        old_status = self.__status
mathieui's avatar
mathieui committed
976 977
        if not (old_status.show != status.show
                or old_status.message != status.message):
978 979 980
            return
        self.__status = status
        hide_status_change = config.get_by_tabname('hide_status_change',
Maxime Buquet's avatar
Maxime Buquet committed
981
                                                   self.jid.bare)
982 983 984 985 986 987 988 989 990 991 992 993 994 995
        now = datetime.now()
        dff = now - self.last_remote_message
        if hide_status_change > -1 and dff.total_seconds() > hide_status_change:
            return

        info_c = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
        nick = self.get_nick()
        remote = self.remote_user_color()
        msg = '\x19%(color)s}%(nick)s\x19%(info)s} changed: '
        msg %= {'color': remote, 'nick': nick, 'info': info_c}
        if status.message != old_status.message and status.message:
            msg += 'status: %s, ' % status.message
        if status.show in SHOW_NAME:
            msg += 'show: %s, ' % SHOW_NAME[status.show]
996 997 998 999
        self.add_message(
            InfoMessage(txt=msg[:-2]),
            typ=2,
        )
1000

1001
    def ack_message(self, msg_id: str, msg_jid: JID):
1002 1003 1004
        """
        Ack a message
        """
1005
        new_msg = self._text_buffer.ack_message(msg_id, msg_jid)
1006 1007 1008 1009
        if new_msg:
            self.text_win.modify_message(msg_id, new_msg)
            self.core.refresh_window()

1010
    def nack_message(self, error: str, msg_id: str, msg_jid: JID):
1011
        """
1012
        Non-ack a message (e.g. timeout)
1013 1014 1015 1016 1017 1018 1019 1020
        """
        new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid)
        if new_msg:
            self.text_win.modify_message(msg_id, new_msg)
            self.core.refresh_window()
            return True
        return False

1021 1022 1023 1024
    @command_args_parser.raw
    def command_xhtml(self, xhtml_data):
        message = self.generate_xhtml_message(xhtml_data)
        if message:
1025
            message['type'] = 'chat'