arcanechat-tui 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Cursed Delta, a TUI Delta Chat client for the command line"""
2
+
3
+ APP_NAME = "CursedDelta"
@@ -0,0 +1,5 @@
1
+ """Support for python module excecution"""
2
+
3
+ from .main import main
4
+
5
+ main()
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '0.11.0'
21
+ __version_tuple__ = version_tuple = (0, 11, 0)
@@ -0,0 +1,165 @@
1
+ """Main UI program"""
2
+
3
+ import sys
4
+ from threading import Thread
5
+ from typing import Optional, Tuple
6
+
7
+ import urwid
8
+ from deltachat2 import Client
9
+
10
+ from .cards_widget import CardsWidget
11
+ from .chatlist import CHAT_SELECTED, ChatListWidget
12
+ from .composer import SENDING_MSG_FAILED, ComposerWidget
13
+ from .container import Container
14
+ from .conversation import ConversationWidget
15
+ from .eventcenter import CHATLIST_CHANGED, MESSAGES_CHANGED, EventCenter
16
+ from .util import shorten_text
17
+ from .welcome_widget import WelcomeWidget
18
+
19
+
20
+ class Application:
21
+ """Main application UI"""
22
+
23
+ def __init__(self, client: Client, keymap: dict, theme: dict) -> None:
24
+ self.client = client
25
+ self.keymap = keymap
26
+ self.accid: Optional[int] = None
27
+ eventcenter = EventCenter()
28
+
29
+ self.chatlist = ChatListWidget(client)
30
+ urwid.connect_signal(eventcenter, CHATLIST_CHANGED, self.chatlist.chatlist_changed)
31
+ chatlist_cont = Container(self.chatlist, self._chatlist_keypress)
32
+
33
+ conversation = ConversationWidget(client, theme["background"][-1])
34
+ urwid.connect_signal(self.chatlist, CHAT_SELECTED, conversation.set_chat)
35
+ urwid.connect_signal(eventcenter, MESSAGES_CHANGED, conversation.messages_changed)
36
+ conversation_cont = Container(conversation, self._conversation_keypress)
37
+
38
+ composer = ComposerWidget(client, keymap)
39
+ urwid.connect_signal(self.chatlist, CHAT_SELECTED, composer.set_chat)
40
+ composer_cont = Container(composer, self._composer_keypress)
41
+
42
+ self.cards = CardsWidget()
43
+ self.cards.add("welcome", WelcomeWidget())
44
+ self.cards.add("conversation", conversation_cont)
45
+ self.cards.show("welcome")
46
+
47
+ self.right_side = urwid.Pile([self.cards, (urwid.PACK, composer_cont)])
48
+
49
+ vsep = urwid.AttrMap(urwid.Filler(urwid.Columns([])), "separator")
50
+
51
+ # layout root
52
+ self.main_columns = urwid.Columns(
53
+ [("weight", 1, chatlist_cont), (1, vsep), ("weight", 4, self.right_side)]
54
+ )
55
+ self.frame = urwid.Frame(self.main_columns)
56
+
57
+ self.loop = urwid.MainLoop(
58
+ urwid.AttrMap(self.frame, "background"),
59
+ [(key, *value) for key, value in theme.items()],
60
+ unhandled_input=self._unhandled_keypress,
61
+ )
62
+ self.loop.screen.set_terminal_properties(colors=256)
63
+
64
+ urwid.connect_signal(self.chatlist, CHAT_SELECTED, self.chat_selected)
65
+ urwid.connect_signal(composer, SENDING_MSG_FAILED, self.sending_msg_failed)
66
+ urwid.connect_signal(eventcenter, CHATLIST_CHANGED, self.chatlist_changed)
67
+ urwid.connect_signal(eventcenter, MESSAGES_CHANGED, self.messages_changed)
68
+ client.add_hook(eventcenter.process_core_event)
69
+
70
+ def _print_title(self) -> None:
71
+ badge = 0
72
+ for acc in self.client.rpc.get_all_account_ids():
73
+ badge += len(self.client.rpc.get_fresh_msgs(acc))
74
+ name = self.client.rpc.get_config(self.accid, "displayname")
75
+ if not name:
76
+ name = self.client.rpc.get_config(self.accid, "configured_addr")
77
+ name = shorten_text(name, 30)
78
+ if badge > 0:
79
+ text = f"\x1b]2;({badge if badge < 999 else '+999'}) {name}\x07"
80
+ else:
81
+ text = f"\x1b]2;{name}\x07"
82
+ sys.stdout.write(text)
83
+
84
+ def exit(self) -> None:
85
+ sys.stdout.write("\x1b]2;\x07")
86
+ raise urwid.ExitMainLoop
87
+
88
+ def chat_selected(self, chat: Optional[Tuple[int, int]]) -> None:
89
+ if chat:
90
+ self.cards.show("conversation")
91
+ # focus composer
92
+ self.main_columns.focus_position = 2
93
+ self.right_side.focus_position = 1
94
+ else:
95
+ self.cards.show("welcome")
96
+ # focus chatlist
97
+ self.main_columns.focus_position = 0
98
+
99
+ def messages_changed(self, _client: Client, accid: int, chatid: int, _msgid: int) -> None:
100
+ chat = self.chatlist.selected_chat
101
+ if accid == self.accid and chat and chatid in (chat[1], 0):
102
+ self.loop.draw_screen()
103
+
104
+ def chatlist_changed(self, _client: Client, accid: int) -> None:
105
+ self._print_title()
106
+ if accid == self.accid:
107
+ self.loop.draw_screen()
108
+
109
+ def sending_msg_failed(self, error: str) -> None:
110
+ self.toast(urwid.AttrMap(urwid.Text([" Error: ", error]), "failed"), 5)
111
+
112
+ def toast(self, element: urwid.Widget, duration: int) -> None:
113
+ def reset_footer(*_) -> None:
114
+ if self.frame.footer == element:
115
+ self.frame.footer = None
116
+
117
+ self.frame.footer = element
118
+ self.loop.set_alarm_in(duration, reset_footer)
119
+
120
+ def _unhandled_keypress(self, key: str) -> None:
121
+ if key == self.keymap["quit"]:
122
+ self.exit()
123
+
124
+ def _chatlist_keypress(self, _size: list, key: str) -> Optional[str]:
125
+ if key in ("right", "tab"):
126
+ # give focus to the composer area
127
+ self.main_columns.focus_position = 2
128
+ self.right_side.focus_position = 1
129
+ return None
130
+ return key
131
+
132
+ def _conversation_keypress(self, _size: list, key: str) -> Optional[str]:
133
+ if key in ("tab", "esc"):
134
+ # give focus to the composer area
135
+ self.right_side.focus_position = 1
136
+ return None
137
+ return key
138
+
139
+ def _composer_keypress(self, _size: list, key: str) -> Optional[str]:
140
+ if key == "esc":
141
+ self.chatlist.select_chat(None)
142
+ return None
143
+ return key
144
+
145
+ def run(self, accid: int = 0) -> None:
146
+ rpc = self.client.rpc
147
+ self.accid = accid or self.client.rpc.get_selected_account_id()
148
+ if not self.accid:
149
+ accounts = [rpc.is_configured(accid) for accid in rpc.get_all_account_ids()]
150
+ if accounts:
151
+ self.accid = accounts[0]
152
+ else:
153
+ print("Error: No account configured yet")
154
+ sys.exit(1)
155
+ elif not rpc.is_configured(self.accid):
156
+ print("Error: Account configured yet")
157
+ sys.exit(1)
158
+
159
+ self.chatlist.set_account(self.accid)
160
+ self._print_title()
161
+ Thread(target=self.client.run_forever, daemon=True).start()
162
+ try:
163
+ self.loop.run()
164
+ except KeyboardInterrupt:
165
+ pass
@@ -0,0 +1,35 @@
1
+ """Cards layout widget, a stack of widgets, only one is visible at a time"""
2
+
3
+ from typing import Dict
4
+
5
+ import urwid
6
+
7
+ EMPTY = urwid.Widget()
8
+
9
+
10
+ class CardsWidget(urwid.WidgetPlaceholder):
11
+ """Cards layout widget, a stack of widgets, only one of them is visible at a time"""
12
+
13
+ original_widget: urwid.Widget
14
+
15
+ def __init__(self) -> None:
16
+ super().__init__(EMPTY)
17
+ self.cards: Dict[str, urwid.Widget] = {}
18
+
19
+ def add(self, name: str, widget: urwid.Widget) -> None:
20
+ """Add a new widget to the stack.
21
+
22
+ The name must be an unique string ID to identify the item.
23
+ """
24
+ self.cards[name] = widget
25
+
26
+ def remove(self, name: str) -> urwid.Widget:
27
+ """Remove an item from the stack corresponding to the given name."""
28
+ widget = self.cards.pop(name)
29
+ if widget is self.original_widget:
30
+ self.original_widget = EMPTY
31
+ return widget
32
+
33
+ def show(self, name: str) -> None:
34
+ """Show the widget corresponding to the given name."""
35
+ self.original_widget = self.cards[name]
@@ -0,0 +1,104 @@
1
+ """Chat list widget"""
2
+
3
+ from typing import Any, Callable, Optional, Tuple
4
+
5
+ import urwid
6
+ from deltachat2 import ChatlistFlag, Client
7
+
8
+ from .lazylistwaker import LazyListWalker
9
+ from .util import shorten_text
10
+
11
+ CHAT_SELECTED = "chat_selected"
12
+
13
+
14
+ class ChatListItem(urwid.Button):
15
+ """A single chatlist item"""
16
+
17
+ def __init__(
18
+ self,
19
+ accid: int,
20
+ item: Any,
21
+ selected: bool,
22
+ callback: Callable[["ChatListItem"], None],
23
+ ) -> None:
24
+ super().__init__("", callback)
25
+ self.accid = accid
26
+ self.id = item.id
27
+ elements: list = []
28
+
29
+ if item.is_self_talk:
30
+ color = "#0af"
31
+ icon = "*"
32
+ elif item.is_device_talk:
33
+ color = "#888"
34
+ icon = "i"
35
+ else:
36
+ color = item.color
37
+ icon = "@" if item.dm_chat_contact else "#"
38
+ elements.append((urwid.AttrSpec("#fff", color), f" {icon} "))
39
+
40
+ if item.is_pinned:
41
+ elements.append("*")
42
+ else:
43
+ elements.append(" ")
44
+
45
+ name = shorten_text(item.name, 40)
46
+ elements.append(("selected_chat", name) if selected else name)
47
+
48
+ if item.fresh_message_counter > 0:
49
+ elements.append(" ")
50
+ style = "unread_badge_muted" if item.is_muted else "unread_badge"
51
+ elements.append((style, f"({item.fresh_message_counter})"))
52
+
53
+ self._w = urwid.AttrMap(urwid.SelectableIcon(elements, 3), None, focus_map="focused_item")
54
+
55
+
56
+ class ChatListWidget(urwid.ListBox):
57
+ """Display a list of chats"""
58
+
59
+ signals = [CHAT_SELECTED]
60
+
61
+ def __init__(self, client: Client) -> None:
62
+ self.client = client
63
+ self.accid: Optional[int] = None
64
+ self.selected_chat: Optional[Tuple[int, int]] = None
65
+ super().__init__(LazyListWalker([], self._create_chatlist_item))
66
+
67
+ def set_account(self, accid: Optional[int]) -> None:
68
+ self.accid = accid
69
+ if self.selected_chat and accid != self.selected_chat[0]:
70
+ self._select_chat(None)
71
+ self.chatlist_changed(self.client, accid)
72
+
73
+ def select_chat(self, chat: Optional[Tuple[int, int]]) -> None:
74
+ self._select_chat(chat)
75
+ self.chatlist_changed(self.client, self.accid)
76
+
77
+ def chatlist_changed(self, client: Client, accid: Optional[int]) -> None:
78
+ if not self.accid:
79
+ self.body.clear_cache()
80
+ self.body.clear()
81
+ elif accid == self.accid:
82
+ entries = client.rpc.get_chatlist_entries(accid, ChatlistFlag.NO_SPECIALS, None, None)
83
+ item = self.focus
84
+ self.body.clear_cache()
85
+ self.body[:] = [(accid, chatid) for chatid in entries]
86
+ try:
87
+ index = max(entries.index(item.id), 0) if item else 0
88
+ except ValueError:
89
+ pass
90
+ else:
91
+ if entries:
92
+ self.set_focus(index)
93
+
94
+ def _create_chatlist_item(self, chat: Tuple[int, int]) -> urwid.Widget:
95
+ item = self.client.rpc.get_chatlist_items_by_entries(chat[0], [chat[1]])[str(chat[1])]
96
+ return ChatListItem(chat[0], item, self.selected_chat == chat, self._on_item_clicked)
97
+
98
+ def _on_item_clicked(self, item: ChatListItem) -> None:
99
+ self._select_chat((item.accid, item.id))
100
+ self.chatlist_changed(self.client, self.accid) # so the selected chat widget is updated
101
+
102
+ def _select_chat(self, chat: Optional[Tuple[int, int]]) -> None:
103
+ self.selected_chat = chat
104
+ urwid.emit_signal(self, CHAT_SELECTED, self.selected_chat)
arcanechat_tui/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ """Command line arguments parsing"""
2
+
3
+ import sys
4
+ from argparse import ArgumentParser, Namespace
5
+ from typing import Callable
6
+
7
+ from appdirs import user_config_dir
8
+ from deltachat2 import Client, JsonRpcError
9
+
10
+ from . import APP_NAME
11
+ from ._version import __version__
12
+ from .util import abspath, get_account, get_or_create_account, parse_docstring
13
+
14
+
15
+ class Cli:
16
+ """Command line argument parser"""
17
+
18
+ def __init__(self) -> None:
19
+ self._parser = ArgumentParser()
20
+ self._subcommands = self._parser.add_subparsers(title="subcommands")
21
+
22
+ self._parser.add_argument("-v", "--version", action="version", version=__version__)
23
+
24
+ self._parser.add_argument(
25
+ "--program-folder",
26
+ "-f",
27
+ help="program configuration folder (default: %(default)s)",
28
+ metavar="PATH",
29
+ default=user_config_dir(APP_NAME),
30
+ type=abspath,
31
+ )
32
+
33
+ self._parser.add_argument(
34
+ "--account",
35
+ "-a",
36
+ help="operate only over the given account when running any subcommand",
37
+ metavar="ADDR",
38
+ )
39
+
40
+ self._parser.add_argument(
41
+ "--log",
42
+ help=(
43
+ "set the severity level of what should be saved in the log file"
44
+ " (default: %(default)s)"
45
+ ),
46
+ choices=["debug", "info", "warning", "error", "disabled"],
47
+ default="warning",
48
+ type=str.lower,
49
+ )
50
+
51
+ init_parser = self.add_subcommand(init_cmd, name="init")
52
+ init_parser.add_argument("addr", help="your e-mail address")
53
+ init_parser.add_argument("password", help="your password")
54
+
55
+ import_parser = self.add_subcommand(import_cmd, name="import")
56
+ import_parser.add_argument("path", help="path to the backup file to import")
57
+
58
+ config_parser = self.add_subcommand(config_cmd, name="config")
59
+ config_parser.add_argument("option", help="option name", nargs="?")
60
+ config_parser.add_argument("value", help="option value to set", nargs="?")
61
+
62
+ def add_subcommand(
63
+ self,
64
+ func: Callable[["Cli", Namespace], None],
65
+ **kwargs,
66
+ ) -> ArgumentParser:
67
+ """Add a subcommand to the CLI."""
68
+ if not kwargs.get("name"):
69
+ kwargs["name"] = func.__name__
70
+ if not kwargs.get("help") and not kwargs.get("description"):
71
+ kwargs["help"], kwargs["description"] = parse_docstring(func.__doc__)
72
+ subparser = self._subcommands.add_parser(**kwargs)
73
+ subparser.set_defaults(cmd=func)
74
+ return subparser
75
+
76
+ def parse_args(self) -> Namespace:
77
+ """Parse command line arguments"""
78
+ return self._parser.parse_args()
79
+
80
+
81
+ def init_cmd(client: Client, args: Namespace) -> None:
82
+ """initialize an account"""
83
+ if args.account:
84
+ accid = get_account(client.rpc, args.account)
85
+ if not accid or accid not in client.rpc.get_all_account_ids():
86
+ print(f"Error: unknown account: {args.account!r}")
87
+ sys.exit(1)
88
+ else:
89
+ accid = get_or_create_account(client.rpc, args.addr)
90
+
91
+ try:
92
+ client.configure(accid, email=args.addr, password=args.password)
93
+ print("Account configured successfully.")
94
+ except JsonRpcError as err:
95
+ print("ERROR: Configuration failed:", err)
96
+ sys.exit(1)
97
+
98
+
99
+ def import_cmd(client: Client, args: Namespace) -> None:
100
+ """import account from backup file"""
101
+ if args.account:
102
+ print("Error: -a/--account can't be used with this subcommand")
103
+ sys.exit(1)
104
+
105
+ try:
106
+ client.rpc.import_backup(client.rpc.add_account(), args.path, None)
107
+ print("Backup imported successfully.")
108
+ except JsonRpcError as err:
109
+ print("ERROR: Failed to import backup:", err)
110
+ sys.exit(1)
111
+
112
+
113
+ def config_cmd(client: Client, args: Namespace) -> None:
114
+ """set or get account configuration values"""
115
+ accounts = client.rpc.get_all_account_ids()
116
+ if not args.account and len(accounts) == 1:
117
+ args.account = accounts[0]
118
+
119
+ if args.account:
120
+ accid = get_account(client.rpc, args.account)
121
+ if not accid or accid not in accounts:
122
+ print(f"Error: unknown account: {args.account!r}")
123
+ sys.exit(1)
124
+
125
+ keys = (client.rpc.get_config(accid, "sys.config_keys") or "").split()
126
+ if args.option and not args.option.startswith("ui.") and args.option not in keys:
127
+ print(f"Error: unknown configuration option: {args.option}")
128
+ sys.exit(1)
129
+
130
+ if args.value:
131
+ client.rpc.set_config(accid, args.option, args.value)
132
+
133
+ if args.option:
134
+ try:
135
+ value = client.rpc.get_config(accid, args.option)
136
+ print(f"{args.option}={value!r}")
137
+ except JsonRpcError:
138
+ print(f"Error: unknown configuration option: {args.option}")
139
+ sys.exit(1)
140
+ else:
141
+ for key in keys:
142
+ value = client.rpc.get_config(accid, key)
143
+ print(f"{key}={value!r}")
144
+ else:
145
+ print(
146
+ "Error: you must use --account option to set what account to set/get"
147
+ " configuration values"
148
+ )
149
+ sys.exit(1)
@@ -0,0 +1,86 @@
1
+ """Composer area widget"""
2
+
3
+ from typing import Dict, Optional, Tuple
4
+
5
+ import urwid
6
+ import urwid_readline
7
+ from deltachat2 import Client, JsonRpcError, MsgData
8
+
9
+ from ._version import __version__
10
+ from .util import get_subtitle, shorten_text
11
+
12
+ SENDING_MSG_FAILED = "sending_msg_failed"
13
+
14
+
15
+ class ReadlineEdit2(urwid_readline.ReadlineEdit):
16
+ """Edit widget"""
17
+
18
+ def __init__(self, insert_new_line_key: str) -> None:
19
+ super().__init__(multiline=True)
20
+ del self.keymap["enter"]
21
+ self.keymap[insert_new_line_key] = self.insert_new_line
22
+
23
+ def previous_line(self):
24
+ """Patch bug: https://github.com/rr-/urwid_readline/issues/24"""
25
+ x, y = self.get_cursor_coords(self.size)
26
+ return self.move_cursor_to_coords(self.size, x, y - 1)
27
+
28
+
29
+ class ComposerWidget(urwid.Filler):
30
+ """Composer area and chat status bar"""
31
+
32
+ signals = [SENDING_MSG_FAILED]
33
+
34
+ def __init__(self, client: Client, keymap: Dict[str, str]) -> None:
35
+ self.client = client
36
+ self.keymap = keymap
37
+ self.chat: Optional[Tuple[int, int]] = None
38
+ self.status_bar = urwid.Text(("status_bar", ""), align="left")
39
+ self.edit_widget = ReadlineEdit2(keymap["insert_new_line"])
40
+ prompt = urwid.Columns([(urwid.PACK, urwid.Text("> ")), self.edit_widget])
41
+ super().__init__(urwid.Pile([urwid.AttrMap(self.status_bar, "status_bar"), prompt]))
42
+ self._update_status_bar(None)
43
+
44
+ def set_chat(self, chat: Optional[Tuple[int, int]]) -> None:
45
+ self.chat = chat
46
+ self.edit_widget.set_edit_text("")
47
+ self.edit_widget.set_edit_pos(0)
48
+ self._update_status_bar(chat)
49
+
50
+ def _send_message(self, text) -> None:
51
+ accid, chatid = self.chat or (0, 0)
52
+ if accid:
53
+ chat = self.client.rpc.get_basic_chat_info(accid, chatid)
54
+ if chat.is_contact_request or chat.is_protection_broken:
55
+ # accept contact requests automatically on sending
56
+ self.client.rpc.accept_chat(accid, chatid)
57
+ try:
58
+ self.client.rpc.send_msg(accid, chatid, MsgData(text=text))
59
+ except JsonRpcError:
60
+ errmsg = "Message could not be sent, are you a member of the chat?"
61
+ urwid.emit_signal(self, SENDING_MSG_FAILED, errmsg)
62
+ else:
63
+ urwid.emit_signal(self, SENDING_MSG_FAILED, "No chat selected")
64
+
65
+ def _update_status_bar(self, chat: Optional[Tuple[int, int]]) -> None:
66
+ if chat:
67
+ info = self.client.rpc.get_basic_chat_info(*chat)
68
+ verified = "✓ " if info.is_protected or info.is_device_chat else ""
69
+ muted = " (muted)" if info.is_muted else ""
70
+ name = shorten_text(info.name, 40)
71
+
72
+ subtitle = shorten_text(get_subtitle(self.client.rpc, chat[0], info), 40)
73
+ text = f" {verified}[ {name} ]{muted} -- {subtitle}"
74
+ else:
75
+ text = f" Cursed Delta {__version__}"
76
+
77
+ self.status_bar.set_text(text)
78
+
79
+ def keypress(self, size: list, key: str) -> Optional[str]:
80
+ if key == self.keymap["send_msg"]:
81
+ text = self.edit_widget.get_edit_text().strip()
82
+ if text:
83
+ self.edit_widget.set_edit_text("")
84
+ self._send_message(text)
85
+ return None
86
+ return super().keypress(size, key)
@@ -0,0 +1,31 @@
1
+ """Widget container that handles key presses in the contained widget."""
2
+
3
+ from typing import Callable, Optional
4
+
5
+ import urwid
6
+
7
+
8
+ class Container(urwid.WidgetPlaceholder):
9
+ """Widget container that handles key presses in the contained widget."""
10
+
11
+ def __init__(
12
+ self,
13
+ widget: urwid.Widget,
14
+ callback: Callable[[list, str], Optional[str]],
15
+ unhandled_only: bool = True,
16
+ ) -> None:
17
+ """
18
+ :param widget: the contained widget
19
+ :param callback: the callback to call on key presses unhandled by the contained widget
20
+ :param unhandled_only: if set to False call callback first and if it didn't handle
21
+ the key press then pass it to the contained widget. If set to True
22
+ only call callback on unhandled key presses
23
+ """
24
+ self._callback = callback
25
+ self._unhandled_only = unhandled_only
26
+ super().__init__(widget)
27
+
28
+ def keypress(self, size: list, key: str) -> Optional[str]:
29
+ if self._unhandled_only:
30
+ return super().keypress(size, key) and self._callback(size, key)
31
+ return self._callback(size, key) and super().keypress(size, key)