mastui 0.3.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.
mastui/__init__.py ADDED
File without changes
mastui/app.css ADDED
@@ -0,0 +1,302 @@
1
+ Screen {
2
+ layout: vertical;
3
+ }
4
+
5
+ Timelines {
6
+ layout: horizontal;
7
+ height: 1fr;
8
+ }
9
+
10
+ Timeline {
11
+ layout: vertical;
12
+ border: solid $accent;
13
+ width: 1fr;
14
+ height: 100%;
15
+ margin: 1;
16
+ }
17
+
18
+ .timeline-content {
19
+ /* This is now empty, as display is handled by the widget itself */
20
+ }
21
+
22
+ Timeline:focus {
23
+ border: double $accent-darken-2;
24
+ }
25
+
26
+ .timeline-header {
27
+ height: 1;
28
+ background: $accent;
29
+ color: $text;
30
+ dock: top;
31
+ }
32
+
33
+ .timeline_title {
34
+ width: 1fr;
35
+ height: 1;
36
+ text-align: center;
37
+ padding: 0 1;
38
+ }
39
+
40
+ .timeline-refresh-spinner {
41
+ dock: right;
42
+ width: auto;
43
+ height: 1;
44
+ display: none;
45
+ margin: 0 1;
46
+ }
47
+
48
+ .timeline-item {
49
+ height: auto;
50
+ margin-bottom: 1;
51
+ }
52
+
53
+ .boost-header {
54
+ padding: 0 1;
55
+ background: $primary-darken-2;
56
+ color: $text;
57
+ height: 1;
58
+ }
59
+
60
+ SplashScreen > Vertical {
61
+ align: center middle;
62
+ height: 100%;
63
+ }
64
+
65
+
66
+ #logo {
67
+ width: auto;
68
+ height: auto;
69
+ }
70
+
71
+ #version {
72
+ width: auto;
73
+ text-align: center;
74
+ }
75
+
76
+ #splash-status {
77
+ width: auto;
78
+ text-align: center;
79
+ }
80
+
81
+ .action-spinner {
82
+ display: none;
83
+ }
84
+
85
+
86
+ .post-footer {
87
+ height: 1;
88
+ padding: 0 1;
89
+ background: $surface-darken-1;
90
+ }
91
+
92
+ .post-footer > Static {
93
+ width: 1fr;
94
+ text-align: center;
95
+ }
96
+
97
+ .status-message {
98
+ margin-top: 1;
99
+ text-align: center;
100
+ }
101
+
102
+ Post.favourited {
103
+ background: $success 20%;
104
+ }
105
+
106
+ Post.reblogged {
107
+ background: $warning 20%;
108
+ }
109
+
110
+ .timeline-item.selected {
111
+ background: $primary 20%;
112
+ }
113
+
114
+ Post.selected {
115
+ background: $primary 20%;
116
+ }
117
+
118
+ Notification.selected {
119
+ background: $primary 20%;
120
+ }
121
+
122
+
123
+ #dialog {
124
+ grid-size: 2;
125
+ grid-gutter: 1;
126
+ align: center middle;
127
+ width: 80%;
128
+ height: auto;
129
+ max-height: 80%;
130
+ background: $surface;
131
+ border: thick $primary 80%;
132
+ padding: 1;
133
+ }
134
+
135
+ #dialog > .hidden {
136
+ display: none;
137
+ }
138
+
139
+ #auth_link_container {
140
+ column-span: 2;
141
+ margin-top: 1;
142
+ border: round $primary;
143
+ padding: 1;
144
+ }
145
+
146
+ #auth_link_container > TextArea {
147
+ height: 5;
148
+ }
149
+
150
+ #auth_link_container.hidden {
151
+ display: none;
152
+ }
153
+
154
+ #status {
155
+ column-span: 2;
156
+ text-align: center;
157
+ color: $error;
158
+ height: auto;
159
+ }
160
+
161
+ #status.hidden {
162
+ display: none;
163
+ }
164
+
165
+ #post_dialog {
166
+ grid-size: 1;
167
+ grid-gutter: 1;
168
+ align: center middle;
169
+ width: 80%;
170
+ height: auto;
171
+ max-height: 80%;
172
+ background: $surface;
173
+ border: thick $primary 80%;
174
+ padding: 1;
175
+ }
176
+
177
+ #post_title {
178
+ width: 100%;
179
+ text-align: center;
180
+ background: $primary;
181
+ color: $text;
182
+ padding: 0 1;
183
+ }
184
+
185
+ #post_content {
186
+ height: 10;
187
+ }
188
+
189
+ #post_options {
190
+ height: auto;
191
+ }
192
+
193
+ #post_language_container {
194
+ height: auto;
195
+ }
196
+
197
+ .post_option_label {
198
+ width: auto;
199
+ height: 100%;
200
+ content-align: left middle;
201
+ }
202
+
203
+ #cw_input {
204
+ width: 1fr;
205
+ }
206
+
207
+ #language_select {
208
+ width: 1fr;
209
+ }
210
+
211
+ #post_buttons {
212
+ width: 100%;
213
+ align-horizontal: right;
214
+ height: auto;
215
+ }
216
+
217
+ #post_buttons > Button {
218
+ margin-left: 1;
219
+ }
220
+
221
+ #reply_dialog {
222
+ grid-size: 1;
223
+ grid-gutter: 1;
224
+ align: center middle;
225
+ width: 80%;
226
+ height: auto;
227
+ max-height: 90%;
228
+ background: $surface;
229
+ border: thick $primary 80%;
230
+ padding: 1;
231
+ }
232
+
233
+ #reply_title {
234
+ width: 100%;
235
+ text-align: center;
236
+ background: $primary;
237
+ color: $text;
238
+ padding: 0 1;
239
+ }
240
+
241
+ #original_post_preview {
242
+ height: auto;
243
+ max-height: 10;
244
+ overflow: auto;
245
+ margin-bottom: 1;
246
+ }
247
+
248
+ #reply_content {
249
+ height: 10;
250
+ }
251
+
252
+ #reply_options {
253
+ height: auto;
254
+ }
255
+
256
+ #reply_language_container {
257
+ height: auto;
258
+ }
259
+
260
+ .reply_option_label {
261
+ width: auto;
262
+ height: 100%;
263
+ content-align: left middle;
264
+ }
265
+
266
+ #cw_input {
267
+ width: 1fr;
268
+ }
269
+
270
+ #language_select {
271
+ width: 1fr;
272
+ }
273
+
274
+ #reply_buttons {
275
+ width: 100%;
276
+ align-horizontal: right;
277
+ height: auto;
278
+ }
279
+
280
+ #reply_buttons > Button {
281
+ margin-left: 1;
282
+ }
283
+ #thread-dialog {
284
+ width: 80%;
285
+ height: 80%;
286
+ background: $surface;
287
+ border: thick $primary 80%;
288
+ padding: 1;
289
+ }
290
+
291
+ #thread-container {
292
+ padding: 1;
293
+ }
294
+
295
+ .main-post {
296
+ border: round $accent;
297
+ }
298
+
299
+ .reply-post {
300
+ margin-left: 4;
301
+ }
302
+
mastui/app.py ADDED
@@ -0,0 +1,275 @@
1
+ from textual.app import App, ComposeResult
2
+ from textual.widgets import Header, Footer
3
+ from textual import on
4
+ from mastui.login import LoginScreen
5
+ from mastui.post import PostScreen
6
+ from mastui.reply import ReplyScreen
7
+ from mastui.splash import SplashScreen
8
+ from mastui.mastodon_api import get_api
9
+ from mastui.timeline import Timelines, Timeline
10
+ from mastui.widgets import Post, LikePost, BoostPost
11
+ from mastui.thread import ThreadScreen
12
+ from mastui.profile import ProfileScreen
13
+ from mastui.logging_config import setup_logging
14
+ import logging
15
+ import argparse
16
+ import os
17
+ from mastui.config import config
18
+ from mastui.messages import (
19
+ PostStatusUpdate,
20
+ ActionFailed,
21
+ TimelineData,
22
+ FocusNextTimeline,
23
+ FocusPreviousTimeline,
24
+ ViewProfile,
25
+ )
26
+
27
+ # Set up logging
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ # Get the absolute path to the CSS file
32
+ css_path = os.path.join(os.path.dirname(__file__), "app.css")
33
+
34
+
35
+ class Mastui(App):
36
+ """A Textual app to interact with Mastodon."""
37
+
38
+ BINDINGS = [
39
+ ("d", "toggle_dark", "Toggle dark mode"),
40
+ ("r", "refresh_timelines", "Refresh timelines"),
41
+ ("c", "compose_post", "Compose post"),
42
+ ("p", "view_profile", "View profile"),
43
+ ("a", "reply_to_post", "Reply to post"),
44
+ ("l", "like_post", "Like post"),
45
+ ("b", "boost_post", "Boost post"),
46
+ ("up", "scroll_up", "Scroll up"),
47
+ ("down", "scroll_down", "Scroll down"),
48
+ ]
49
+ CSS_PATH = css_path
50
+ initial_data = None
51
+
52
+ def compose(self) -> ComposeResult:
53
+ """Create child widgets for the app."""
54
+ yield Header()
55
+ yield Footer()
56
+
57
+ def on_mount(self) -> None:
58
+ """Called when the app is mounted."""
59
+ self.dark = config.theme == "dark"
60
+ self.push_screen(SplashScreen())
61
+ self.api = get_api()
62
+ if self.api:
63
+ self.load_initial_data()
64
+ else:
65
+ self.call_later(self.show_login_screen)
66
+
67
+ def show_login_screen(self):
68
+ if isinstance(self.screen, SplashScreen):
69
+ self.pop_screen()
70
+ self.push_screen(LoginScreen(), self.on_login)
71
+
72
+ def load_initial_data(self):
73
+ """Starts the worker to load initial data."""
74
+ self.run_worker(self.do_load_initial_data, exclusive=True, thread=True)
75
+
76
+ def do_load_initial_data(self):
77
+ """Worker to load initial data from the Mastodon API."""
78
+ log.info("Starting initial data load...")
79
+ def update_status(message):
80
+ if isinstance(self.screen, SplashScreen):
81
+ self.call_from_thread(self.screen.update_status, message)
82
+
83
+ try:
84
+ update_status("Fetching home timeline...")
85
+ home_timeline = self.api.timeline_home()
86
+ log.info(f"Fetched {len(home_timeline)} posts for home timeline.")
87
+
88
+ update_status("Fetching notifications...")
89
+ notifications = self.api.notifications()
90
+ log.info(f"Fetched {len(notifications)} notifications.")
91
+
92
+ update_status("Fetching federated timeline...")
93
+ federated_timeline = self.api.timeline_public()
94
+ log.info(f"Fetched {len(federated_timeline)} posts for federated timeline.")
95
+
96
+ self.initial_data = {
97
+ "home": home_timeline,
98
+ "notifications": notifications,
99
+ "federated": federated_timeline,
100
+ }
101
+ log.info("Initial data load complete.")
102
+ self.call_from_thread(self.show_timelines)
103
+ except Exception as e:
104
+ log.error(f"Error loading initial data: {e}", exc_info=True)
105
+ self.notify(f"Error loading initial data: {e}", severity="error")
106
+ self.call_from_thread(self.show_login_screen)
107
+
108
+ def on_login(self, api) -> None:
109
+ """Called when the login screen is dismissed."""
110
+ log.info("Login successful.")
111
+ self.api = api
112
+ self.push_screen(SplashScreen())
113
+ self.load_initial_data()
114
+
115
+ def show_timelines(self):
116
+ if isinstance(self.screen, SplashScreen):
117
+ self.pop_screen()
118
+ log.info("Showing timelines...")
119
+ self.mount(Timelines(initial_data=self.initial_data))
120
+ try:
121
+ self.query_one("#home", Timeline).focus()
122
+ except Exception:
123
+ pass
124
+
125
+ def watch_dark(self, dark: bool) -> None:
126
+ """Called when dark mode is toggled."""
127
+ config.theme = "dark" if dark else "light"
128
+ config.save_config()
129
+
130
+ def action_refresh_timelines(self) -> None:
131
+ """An action to refresh the timelines."""
132
+ log.info("Refreshing all timelines...")
133
+ for timeline in self.query(Timeline):
134
+ timeline.refresh_posts()
135
+
136
+ def action_compose_post(self) -> None:
137
+ """An action to compose a new post."""
138
+ self.push_screen(PostScreen(), self.on_post_screen_dismiss)
139
+
140
+ def action_reply_to_post(self) -> None:
141
+ focused = self.query("Timeline:focus")
142
+ if focused:
143
+ focused.first().reply_to_post()
144
+
145
+ def on_post_screen_dismiss(self, result: dict) -> None:
146
+ """Called when the post screen is dismissed."""
147
+ if result:
148
+ try:
149
+ log.info("Sending post...")
150
+ self.api.status_post(
151
+ status=result["content"],
152
+ spoiler_text=result["spoiler_text"],
153
+ language=result["language"],
154
+ )
155
+ log.info("Post sent successfully.")
156
+ self.notify("Post sent successfully!", severity="information")
157
+ self.action_refresh_timelines()
158
+ except Exception as e:
159
+ log.error(f"Error sending post: {e}", exc_info=True)
160
+ self.notify(f"Error sending post: {e}", severity="error")
161
+
162
+ def on_reply_screen_dismiss(self, result: dict) -> None:
163
+ """Called when the reply screen is dismissed."""
164
+ if result:
165
+ try:
166
+ log.info(f"Sending reply to post {result['in_reply_to_id']}...")
167
+ self.api.status_post(
168
+ status=result["content"],
169
+ spoiler_text=result["spoiler_text"],
170
+ language=result["language"],
171
+ in_reply_to_id=result["in_reply_to_id"],
172
+ )
173
+ log.info("Reply sent successfully.")
174
+ self.notify("Reply sent successfully!", severity="information")
175
+ self.action_refresh_timelines()
176
+ except Exception as e:
177
+ log.error(f"Error sending reply: {e}", exc_info=True)
178
+ self.notify(f"Error sending reply: {e}", severity="error")
179
+
180
+ @on(LikePost)
181
+ def handle_like_post(self, message: LikePost):
182
+ self.run_worker(lambda: self.do_like_post(message.post_id), exclusive=True, thread=True)
183
+
184
+ def do_like_post(self, post_id: str):
185
+ try:
186
+ post_data = self.api.status_favourite(post_id)
187
+ self.post_message(PostStatusUpdate(post_data))
188
+ except Exception as e:
189
+ log.error(f"Error liking post {post_id}: {e}", exc_info=True)
190
+ self.post_message(ActionFailed(post_id))
191
+
192
+ @on(BoostPost)
193
+ def handle_boost_post(self, message: BoostPost):
194
+ self.run_worker(lambda: self.do_boost_post(message.post_id), exclusive=True, thread=True)
195
+
196
+ def do_boost_post(self, post_id: str):
197
+ try:
198
+ post_data = self.api.status_reblog(post_id)
199
+ self.post_message(PostStatusUpdate(post_data))
200
+ except Exception as e:
201
+ log.error(f"Error boosting post {post_id}: {e}", exc_info=True)
202
+ self.post_message(ActionFailed(post_id))
203
+
204
+ def on_post_status_update(self, message: PostStatusUpdate) -> None:
205
+ updated_post_data = message.post_data
206
+ target_post = updated_post_data.get("reblog") or updated_post_data
207
+ target_id = target_post["id"]
208
+
209
+ for container in [self.screen, *self.query(Timelines)]:
210
+ for post_widget in container.query(Post):
211
+ original_status = post_widget.post.get("reblog") or post_widget.post
212
+ if original_status["id"] == target_id:
213
+ post_widget.update_from_post(updated_post_data)
214
+
215
+ def on_action_failed(self, message: ActionFailed) -> None:
216
+ for container in [self.screen, *self.query(Timelines)]:
217
+ for post_widget in container.query(Post):
218
+ original_status = post_widget.post.get("reblog") or post_widget.post
219
+ if original_status["id"] == message.post_id:
220
+ post_widget.hide_spinner()
221
+
222
+ @on(FocusNextTimeline)
223
+ def on_focus_next_timeline(self, message: FocusNextTimeline) -> None:
224
+ timelines = self.query(Timeline)
225
+ for i, timeline in enumerate(timelines):
226
+ if timeline.has_focus:
227
+ timelines[(i + 1) % len(timelines)].focus()
228
+ return
229
+
230
+ @on(FocusPreviousTimeline)
231
+ def on_focus_previous_timeline(self, message: FocusPreviousTimeline) -> None:
232
+ timelines = self.query(Timeline)
233
+ for i, timeline in enumerate(timelines):
234
+ if timeline.has_focus:
235
+ timelines[(i - 1) % len(timelines)].focus()
236
+ return
237
+
238
+ @on(ViewProfile)
239
+ def on_view_profile(self, message: ViewProfile) -> None:
240
+ self.push_screen(ProfileScreen(message.account_id, self.api))
241
+
242
+ def action_like_post(self) -> None:
243
+ focused = self.query("Timeline:focus")
244
+ if focused:
245
+ focused.first().like_post()
246
+
247
+ def action_boost_post(self) -> None:
248
+ focused = self.query("Timeline:focus")
249
+ if focused:
250
+ focused.first().boost_post()
251
+
252
+ def action_scroll_up(self) -> None:
253
+ focused = self.query("Timeline:focus")
254
+ if focused:
255
+ focused.first().scroll_up()
256
+
257
+ def action_scroll_down(self) -> None:
258
+ focused = self.query("Timeline:focus")
259
+ if focused:
260
+ focused.first().scroll_down()
261
+
262
+
263
+ def main():
264
+ parser = argparse.ArgumentParser(description="A Textual app to interact with Mastodon.")
265
+ parser.add_argument("--no-ssl-verify", action="store_false", dest="ssl_verify", help="Disable SSL verification.")
266
+ parser.add_argument("--debug", action="store_true", help="Enable debug logging.")
267
+ args = parser.parse_args()
268
+ setup_logging(debug=args.debug)
269
+ config.ssl_verify = args.ssl_verify
270
+ app = Mastui()
271
+ app.run()
272
+
273
+
274
+ if __name__ == "__main__":
275
+ main()
mastui/config.py ADDED
@@ -0,0 +1,35 @@
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv()
5
+
6
+ class Config:
7
+ def __init__(self):
8
+ self.mastodon_host = os.getenv("MASTODON_HOST")
9
+ self.mastodon_client_id = os.getenv("MASTODON_CLIENT_ID")
10
+ self.mastodon_client_secret = os.getenv("MASTODON_CLIENT_SECRET")
11
+ self.mastodon_access_token = os.getenv("MASTODON_ACCESS_TOKEN")
12
+ self.ssl_verify = True
13
+ self.theme = os.getenv("THEME", "dark")
14
+
15
+ def save_config(self):
16
+ with open(".env", "w") as f:
17
+ if self.mastodon_host:
18
+ f.write(f"MASTODON_HOST={self.mastodon_host}\n")
19
+ if self.mastodon_client_id:
20
+ f.write(f"MASTODON_CLIENT_ID={self.mastodon_client_id}\n")
21
+ if self.mastodon_client_secret:
22
+ f.write(f"MASTODON_CLIENT_SECRET={self.mastodon_client_secret}\n")
23
+ if self.mastodon_access_token:
24
+ f.write(f"MASTODON_ACCESS_TOKEN={self.mastodon_access_token}\n")
25
+ if self.theme:
26
+ f.write(f"THEME={self.theme}\n")
27
+
28
+ def save_credentials(self, host, client_id, client_secret, access_token):
29
+ self.mastodon_host = host
30
+ self.mastodon_client_id = client_id
31
+ self.mastodon_client_secret = client_secret
32
+ self.mastodon_access_token = access_token
33
+ self.save_config()
34
+
35
+ config = Config()
@@ -0,0 +1,13 @@
1
+ import logging
2
+ import os
3
+
4
+ def setup_logging(debug=False):
5
+ """Set up logging to a file."""
6
+ log_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "mastui.log")
7
+ level = logging.DEBUG if debug else logging.INFO
8
+ logging.basicConfig(
9
+ level=level,
10
+ format="%(asctime)s - %(levelname)s - %(message)s",
11
+ filename=log_file,
12
+ filemode="w", # Overwrite the log file on each run
13
+ )
mastui/login.py ADDED
@@ -0,0 +1,76 @@
1
+ import pyperclip
2
+ from textual.app import App, ComposeResult
3
+ from textual.containers import Grid, Vertical
4
+ from textual.widgets import Button, Label, Input, Static, TextArea
5
+ from textual.screen import ModalScreen
6
+
7
+ from mastui.mastodon_api import login, create_app
8
+
9
+ class LoginScreen(ModalScreen):
10
+ """Screen for user to login."""
11
+
12
+ def compose(self) -> ComposeResult:
13
+ with Grid(id="dialog"):
14
+ yield Label("Mastodon Instance:")
15
+ yield Input(placeholder="mastodon.social", id="host")
16
+
17
+ yield Static() # Spacer
18
+ yield Button("Get Auth Link", variant="primary", id="get_auth")
19
+
20
+ yield Static(id="status", classes="hidden")
21
+
22
+ with Vertical(id="auth_link_container", classes="hidden"):
23
+ yield Static("1. Link copied to clipboard! Open it in your browser to authorize.")
24
+ yield TextArea("", id="auth_link",)
25
+ yield Static("2. Paste the authorization code here:")
26
+ yield Input(placeholder="Authorization Code", id="auth_code")
27
+ yield Button("Login", variant="primary", id="login")
28
+
29
+
30
+ def on_mount(self) -> None:
31
+ """Called when the screen is mounted."""
32
+ self.query_one("#host").focus()
33
+
34
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
35
+ status = self.query_one("#status")
36
+ if event.button.id == "get_auth":
37
+ host = self.query_one("#host").value
38
+ if not host:
39
+ status.update("[red]Please enter a Mastodon instance.[/red]")
40
+ status.remove_class("hidden")
41
+ return
42
+
43
+ status.update("Generating authorization link...")
44
+ status.remove_class("hidden")
45
+ auth_url, error = create_app(host)
46
+ if error:
47
+ status.update(f"[red]Error: {error}[/red]")
48
+ return
49
+
50
+ pyperclip.set_clipboard("xclip")
51
+ pyperclip.copy(auth_url)
52
+
53
+ auth_link_input = self.query_one("#auth_link")
54
+ auth_link_input.text = auth_url
55
+ auth_link_input.read_only = True
56
+ self.query_one("#auth_link_container").remove_class("hidden")
57
+ self.query_one("#get_auth").parent.add_class("hidden") # Hide the button's parent container
58
+ self.query_one("#host").disabled = True
59
+ self.query_one("#auth_code").focus()
60
+ status.add_class("hidden")
61
+
62
+ elif event.button.id == "login":
63
+ auth_code = self.query_one("#auth_code").value
64
+ host = self.query_one("#host").value
65
+ if not auth_code:
66
+ status.update("[red]Please enter the authorization code.[/red]")
67
+ status.remove_class("hidden")
68
+ return
69
+
70
+ status.update("Logging in...")
71
+ status.remove_class("hidden")
72
+ api, error = login(host, auth_code)
73
+ if api:
74
+ self.dismiss(api)
75
+ else:
76
+ status.update(f"[red]Login failed: {error}[/red]")