fusenterm 0.1.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.
fusenterm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """fusenterm - ターミナルのホワイトボードに付箋を貼るアプリ"""
2
+
3
+ __version__ = "0.1.0"
fusenterm/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """fusenterm - エントリーポイント"""
2
+
3
+ from .app import FusenApp
4
+
5
+
6
+ def main() -> None:
7
+ app = FusenApp()
8
+ app.run()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
fusenterm/app.py ADDED
@@ -0,0 +1,319 @@
1
+ """fusenterm - メインアプリケーション"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Optional, Union
8
+
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.widgets import Footer, Header
12
+
13
+ from . import storage
14
+ from .models import DEFAULT_COLOR, Note, color_label, next_color, note_title
15
+ from .screens.about import AboutScreen
16
+ from .screens.confirm import ConfirmDeleteScreen
17
+ from .screens.links import LinksScreen
18
+ from .screens.note_edit import NoteEditScreen
19
+ from .screens.overview import OverviewScreen
20
+ from .screens.settings import SettingsScreen
21
+ from .widgets.board import Board, NoteCard
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ CSS_PATH = Path(__file__).parent / "styles" / "app.tcss"
26
+
27
+ # a キーで続けて足したときに、真上に重ならないようずらす量と周期
28
+ _CASCADE_STEP = (2, 1)
29
+ _CASCADE_COUNT = 6
30
+
31
+
32
+ class FusenApp(App):
33
+ """fusenterm アプリケーション"""
34
+
35
+ TITLE = "fusenterm"
36
+ CSS_PATH = CSS_PATH
37
+ BINDING_GROUP_TITLE = "全体"
38
+
39
+ # 端末のANSIパレット (ユーザーのターミナル配色設定) をそのまま使うテーマ
40
+ THEME = "ansi-dark"
41
+
42
+ # 既定では先頭 (一番奥) の付箋が選ばれて手前に出てしまうので、選ぶのは on_mount に任せる
43
+ AUTO_FOCUS = None
44
+
45
+ # フッターに出すのは使用頻度の高いものだけ。show=False のキーも ? のヘルプには出る
46
+ BINDINGS = [
47
+ Binding("a", "add", "追加"),
48
+ Binding("e,enter", "edit", "編集"),
49
+ Binding("c", "cycle_color", "色"),
50
+ Binding("d", "delete", "剥がす"),
51
+ Binding("tab", "focus_next", "次の付箋"),
52
+ Binding("z", "overview", "全体"),
53
+ Binding("o", "links", "リンク"),
54
+ Binding("x", "hold", "持つ"),
55
+ Binding("p", "put", "ここに置く"),
56
+ Binding("comma", "settings", "設定"),
57
+ Binding("question_mark", "show_help_panel", "ヘルプ"),
58
+ Binding("q", "quit", "終了"),
59
+ Binding("shift+tab", "focus_previous", "前の付箋", show=False),
60
+ Binding("u", "undo", "剥がした付箋を戻す", show=False),
61
+ Binding("i", "about", "このアプリについて", show=False),
62
+ Binding("escape", "hide_help_panel", "ヘルプを閉じる", show=False),
63
+ ]
64
+
65
+ def __init__(self) -> None:
66
+ super().__init__()
67
+ self._notes = storage.load_notes()
68
+ self._settings = storage.load_settings()
69
+ self._deleted: Note | None = None # u キーで戻せる、直前に剥がした付箋
70
+ self._held: NoteCard | None = None # x で持っている、p で今見ている所へ運ぶ付箋
71
+
72
+ def compose(self) -> ComposeResult:
73
+ yield Header(show_clock=True)
74
+ board = Board(id="board")
75
+ board.border_subtitle = "hjkl 選ぶ m 移動モード r サイズ変更モード JK 本文スクロール ドラッグ 移動 右下の角 大きさ"
76
+ yield board
77
+ # コマンドパレットの案内はフッターの幅を食うので出さない
78
+ yield Footer(show_command_palette=False)
79
+
80
+ def on_mount(self) -> None:
81
+ self.theme = self.THEME
82
+ board = self._board()
83
+ self._apply_settings()
84
+ board.mount_all(NoteCard(note) for note in self._notes)
85
+ # 一番手前の付箋から始める
86
+ if self._notes:
87
+ self.call_after_refresh(lambda: board.cards()[-1].focus())
88
+ self._update_title()
89
+
90
+ # ── 参照 ────────────────────────────────
91
+
92
+ def _board(self) -> Board:
93
+ return self.query_one("#board", Board)
94
+
95
+ def _current(self) -> NoteCard | None:
96
+ """選んでいる付箋 (無ければ None)"""
97
+ focused = self.focused
98
+ return focused if isinstance(focused, NoteCard) else None
99
+
100
+ # ── イベント ────────────────────────────
101
+
102
+ def on_note_card_changed(self, event: NoteCard.Changed) -> None:
103
+ self._save()
104
+
105
+ def on_note_card_edit_requested(self, event: NoteCard.EditRequested) -> None:
106
+ self._edit(event.card)
107
+
108
+ def on_note_card_delete_requested(self, event: NoteCard.DeleteRequested) -> None:
109
+ self._delete(event.card)
110
+
111
+ def on_board_add_requested(self, event: Board.AddRequested) -> None:
112
+ self._add_at(event.x, event.y)
113
+
114
+ def on_settings_screen_changed(self, event: SettingsScreen.Changed) -> None:
115
+ self._settings = event.settings
116
+ self._apply_settings()
117
+ try:
118
+ storage.save_settings(self._settings)
119
+ except OSError as e:
120
+ log.warning("設定の保存に失敗しました: %s", e)
121
+ self.notify(f"設定の保存に失敗しました: {e}", severity="error")
122
+
123
+ # ── 操作 ────────────────────────────────
124
+
125
+ def action_add(self) -> None:
126
+ """今見ている範囲の中央付近に付箋を足す (続けて足すと少しずつずらす)"""
127
+ board = self._board()
128
+ sample = Note(text="")
129
+ step = len(board.cards()) % _CASCADE_COUNT
130
+ camera_x, camera_y = board.camera
131
+ width, height = board.view_size()
132
+ x = camera_x + (width - sample.width) // 2 + _CASCADE_STEP[0] * step
133
+ y = camera_y + (height - sample.height) // 2 + _CASCADE_STEP[1] * step
134
+ self._add_at(x, y)
135
+
136
+ def _add_at(self, x: int, y: int) -> None:
137
+ """位置を決めて新しい付箋の編集を開く (本文を書かずに閉じたら足さない)"""
138
+
139
+ def on_result(result: Optional[tuple[str, str]]) -> None:
140
+ if result is None or not result[0]:
141
+ return
142
+ text, color = result
143
+ self._attach(NoteCard(Note(text=text, x=x, y=y, color=color)))
144
+
145
+ self.push_screen(NoteEditScreen("", DEFAULT_COLOR, title="付箋を追加"), on_result)
146
+
147
+ def _attach(self, card: NoteCard) -> None:
148
+ """付箋を一番手前に貼って選ぶ"""
149
+ self._board().mount(card)
150
+ self.call_after_refresh(card.focus)
151
+ self._save()
152
+
153
+ def action_edit(self) -> None:
154
+ card = self._current()
155
+ if card is not None:
156
+ self._edit(card)
157
+
158
+ def _edit(self, card: NoteCard) -> None:
159
+ note = card.note
160
+
161
+ def on_result(result: Optional[tuple[str, str]]) -> None:
162
+ if result is None:
163
+ return
164
+ note.text, note.color = result
165
+ card.sync()
166
+ self._save()
167
+
168
+ self.push_screen(NoteEditScreen(note.text, note.color), on_result)
169
+
170
+ def action_cycle_color(self) -> None:
171
+ card = self._current()
172
+ if card is None:
173
+ return
174
+ card.note.color = next_color(card.note.color)
175
+ card.sync()
176
+ self.notify(f"色: {color_label(card.note.color)}", timeout=1)
177
+ self._save()
178
+
179
+ def action_delete(self) -> None:
180
+ card = self._current()
181
+ if card is not None:
182
+ self._delete(card)
183
+
184
+ def _delete(self, card: NoteCard) -> None:
185
+ """確認してから付箋を剥がす (u で戻せるよう覚えておく)"""
186
+
187
+ def on_result(confirmed: Optional[bool]) -> None:
188
+ if not confirmed:
189
+ return
190
+ self._deleted = card.note
191
+ if card is self._held:
192
+ self._set_held(None)
193
+ card.remove()
194
+ self._save(exclude=card)
195
+ self.notify("剥がしました (u で元に戻す)", timeout=3)
196
+ # 残りのうち一番手前の付箋を選び直す
197
+ rest = [c for c in self._board().cards() if c is not card]
198
+ if rest:
199
+ rest[-1].focus()
200
+
201
+ self.push_screen(ConfirmDeleteScreen(card.note.text), on_result)
202
+
203
+ def action_hold(self) -> None:
204
+ """選んでいる付箋を持つ (持っている付箋でもう一度押すとやめる)
205
+
206
+ 持つだけでボードからは外さない (持ったまま終了しても付箋を失わないように)。
207
+ """
208
+ card = self._current()
209
+ if card is None:
210
+ return
211
+ if card is self._held:
212
+ self._set_held(None)
213
+ self.notify("持つのをやめました", timeout=2)
214
+ return
215
+ self._set_held(card)
216
+ self.notify("持ちました。運びたい所で p (やめるならもう一度 x)", timeout=3)
217
+
218
+ def action_put(self) -> None:
219
+ """持っている付箋を、今見ている範囲の真ん中に置く"""
220
+ card = self._held
221
+ if card is None:
222
+ return
223
+ board = self._board()
224
+ camera_x, camera_y = board.camera
225
+ width, height = board.view_size()
226
+ note = card.note
227
+ note.x = camera_x + (width - note.width) // 2
228
+ note.y = camera_y + (height - note.height) // 2
229
+ card.sync()
230
+ self._set_held(None)
231
+ board.raise_card(card)
232
+ card.focus()
233
+ self._save()
234
+
235
+ def _set_held(self, card: NoteCard | None) -> None:
236
+ """持っている付箋を変える (印と、ボードの題と、p キーの表示を揃える)"""
237
+ if self._held is not None:
238
+ self._held.remove_class("-held")
239
+ self._held = card
240
+ if card is not None:
241
+ card.add_class("-held")
242
+ self._update_title()
243
+ self.refresh_bindings()
244
+
245
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
246
+ # 何も持っていないときは p を出さない
247
+ if action == "put":
248
+ return self._held is not None
249
+ return True
250
+
251
+ def action_overview(self) -> None:
252
+ """縮小表示でボード全体を見て、選んだ付箋か場所へ飛ぶ"""
253
+ board = self._board()
254
+
255
+ def on_result(result: Optional[Union[str, tuple[int, int]]]) -> None:
256
+ if result is None:
257
+ return
258
+ if isinstance(result, tuple):
259
+ board.move_camera(*result)
260
+ return
261
+ card = next((c for c in board.cards() if c.note.id == result), None)
262
+ if card is None:
263
+ return
264
+ note = card.note
265
+ board.center_camera(note.x + note.width // 2, note.y + note.height // 2)
266
+ # 画面より大きい付箋は左上を見せる (選び直さない場合もあるのでここで呼ぶ)
267
+ board.reveal(card)
268
+ card.focus()
269
+
270
+ notes = [c.note for c in board.cards()]
271
+ self.push_screen(OverviewScreen(notes, board.camera, board.view_size()), on_result)
272
+
273
+ def action_links(self) -> None:
274
+ """選んでいる付箋に書かれたリンクを一覧で見せる"""
275
+ card = self._current()
276
+ if card is not None:
277
+ self.push_screen(LinksScreen(card.note.text))
278
+
279
+ def action_settings(self) -> None:
280
+ self.push_screen(SettingsScreen(self._settings))
281
+
282
+ def action_about(self) -> None:
283
+ self.push_screen(AboutScreen())
284
+
285
+ def _apply_settings(self) -> None:
286
+ """設定をボードの見た目に反映する"""
287
+ board = self._board()
288
+ board.set_shadow(self._settings.shadow)
289
+ board.set_background(self._settings.background)
290
+
291
+ def action_undo(self) -> None:
292
+ """直前に剥がした付箋を、元の位置の一番手前に戻す"""
293
+ if self._deleted is None:
294
+ self.notify("戻せる付箋はありません", timeout=2)
295
+ return
296
+ note, self._deleted = self._deleted, None
297
+ self._attach(NoteCard(note))
298
+
299
+ # ── 保存 ────────────────────────────────
300
+
301
+ def _save(self, *, exclude: NoteCard | None = None) -> None:
302
+ """ボードの並び (奥から手前) のまま保存する
303
+
304
+ ``exclude`` は削除処理中でまだボードに残っている付箋。
305
+ """
306
+ self._notes = [c.note for c in self._board().cards() if c is not exclude]
307
+ self._update_title()
308
+ try:
309
+ storage.save_notes(self._notes)
310
+ except OSError as e:
311
+ log.warning("付箋の保存に失敗しました: %s", e)
312
+ self.notify(f"保存に失敗しました: {e}", severity="error")
313
+
314
+ def _update_title(self) -> None:
315
+ title = f"ホワイトボード {len(self._notes)}枚"
316
+ # 持っている付箋が画面の外にあっても分かるように題にも出す
317
+ if self._held is not None:
318
+ title += f" 持っている: {note_title(self._held.note.text)}"
319
+ self._board().border_title = title
fusenterm/clipboard.py ADDED
@@ -0,0 +1,167 @@
1
+ """リンク付きの文字列を貼り付けたときに markdown のリンクにする
2
+
3
+ 端末の貼り付け (ペースト) で届くのはプレーンテキストだけなので、Teams やブラウザから
4
+ コピーしたリンク付きの文字列は URL か文字だけになってしまう。クリップボードには
5
+ 同じ内容の HTML も入っているので、それを読んで ``[文字](URL)`` の形に直す。
6
+
7
+ クリップボードを直接読めるのは macOS だけ (osascript)。他の環境や、読めなかった
8
+ 場合は端末から届いたテキストをそのまま貼る。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ import re
17
+ import sys
18
+ from html.parser import HTMLParser
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # クリップボードのプレーンテキストと HTML を JSON で返す (無い方は null)
23
+ _READ_CLIPBOARD = """
24
+ ObjC.import("AppKit");
25
+ const pb = $.NSPasteboard.generalPasteboard;
26
+ const read = (type) => { const s = pb.stringForType(type); return s.isNil() ? null : s.js; };
27
+ JSON.stringify({text: read("public.utf8-plain-text"), html: read("public.html")});
28
+ """
29
+
30
+ # クリップボードの読み取りを待つ上限 (秒)。超えたら届いたテキストをそのまま貼る
31
+ _TIMEOUT = 2
32
+
33
+ # 改行として扱う (前後で行が変わる) タグ
34
+ _BLOCK_TAGS = {
35
+ "p", "div", "li", "tr", "ul", "ol", "table", "pre", "blockquote",
36
+ "h1", "h2", "h3", "h4", "h5", "h6",
37
+ }
38
+
39
+ # 中身を本文として扱わないタグ
40
+ _SKIP_TAGS = {"script", "style", "head", "title"}
41
+
42
+
43
+ async def markdown_from_paste(pasted: str) -> str | None:
44
+ """貼り付けたテキストをリンク入りの markdown にしたもの (できなければ None)
45
+
46
+ クリップボードのプレーンテキストが ``pasted`` と一致するときだけ HTML を使う
47
+ (別の経路で貼られたテキストに、たまたま残っていた HTML を混ぜないため)。
48
+ """
49
+ clip = await _read_clipboard()
50
+ if clip is None:
51
+ return None
52
+ text, html = clip
53
+ if not html or text is None or _normalize(text) != _normalize(pasted):
54
+ return None
55
+ return html_to_markdown(html)
56
+
57
+
58
+ def html_to_markdown(html: str) -> str | None:
59
+ """HTML のリンクを ``[文字](URL)`` にしたテキスト (リンクが無ければ None)
60
+
61
+ リンク以外の装飾は落とし、段落・改行・箇条書きだけ残す。
62
+ """
63
+ parser = _HtmlToMarkdown()
64
+ parser.feed(html)
65
+ parser.close()
66
+ if not parser.has_link:
67
+ return None
68
+ text = "".join(parser.out)
69
+ # 行頭・行末の空白を落とし、続いた空行は1つにまとめる
70
+ text = "\n".join(line.strip() for line in text.split("\n"))
71
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
72
+
73
+
74
+ def _normalize(text: str) -> str:
75
+ """改行コードと前後の空白の違いを無視して比べるための形"""
76
+ return text.replace("\r\n", "\n").replace("\r", "\n").strip()
77
+
78
+
79
+ def _link(text: str, href: str) -> str:
80
+ """markdown のリンク (文字が無いか URL そのものなら URL だけ)"""
81
+ text = " ".join(text.split())
82
+ if not text or text == href:
83
+ return href
84
+ text = text.replace("[", r"\[").replace("]", r"\]")
85
+ href = href.replace(" ", "%20").replace("(", "%28").replace(")", "%29")
86
+ return f"[{text}]({href})"
87
+
88
+
89
+ class _HtmlToMarkdown(HTMLParser):
90
+ """HTML を読みながら、リンクを markdown にしたテキストを out に貯める"""
91
+
92
+ def __init__(self) -> None:
93
+ super().__init__()
94
+ self.out: list[str] = []
95
+ self.has_link = False
96
+ self._skip = 0 # script などの中にいる深さ
97
+ self._pre = 0 # pre の中 (空白をそのまま残す) にいる深さ
98
+ self._href: str | None = None # 開いているリンクの URL
99
+ self._link_text: list[str] = [] # 開いているリンクの文字
100
+
101
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
102
+ if tag in _SKIP_TAGS:
103
+ self._skip += 1
104
+ elif tag == "a":
105
+ href = dict(attrs).get("href")
106
+ if href and not href.startswith("#"):
107
+ self._href = href.strip()
108
+ self._link_text = []
109
+ elif tag == "br":
110
+ self._write("\n")
111
+ elif tag in _BLOCK_TAGS:
112
+ if tag == "pre":
113
+ self._pre += 1
114
+ self._write("\n- " if tag == "li" else "\n")
115
+
116
+ def handle_endtag(self, tag: str) -> None:
117
+ if tag in _SKIP_TAGS:
118
+ self._skip = max(0, self._skip - 1)
119
+ elif tag == "a" and self._href is not None:
120
+ href, self._href = self._href, None
121
+ self.out.append(_link("".join(self._link_text), href))
122
+ self.has_link = True
123
+ elif tag in _BLOCK_TAGS:
124
+ if tag == "pre":
125
+ self._pre = max(0, self._pre - 1)
126
+ # 項目の区切りは次の li の「- 」の前の改行で足りる (空行を挟まない)
127
+ if tag != "li":
128
+ self._write("\n")
129
+
130
+ def handle_data(self, data: str) -> None:
131
+ if self._skip:
132
+ return
133
+ # HTML では改行やタブも1つの空白として扱われる (pre の中は除く)
134
+ self._write(data if self._pre else re.sub(r"\s+", " ", data))
135
+
136
+ def _write(self, text: str) -> None:
137
+ if self._href is not None:
138
+ self._link_text.append(text)
139
+ else:
140
+ self.out.append(text)
141
+
142
+
143
+ async def _read_clipboard() -> tuple[str | None, str | None] | None:
144
+ """クリップボードの (プレーンテキスト, HTML)。読めなければ None"""
145
+ if sys.platform != "darwin":
146
+ return None
147
+ try:
148
+ proc = await asyncio.create_subprocess_exec(
149
+ "osascript", "-l", "JavaScript", "-e", _READ_CLIPBOARD,
150
+ stdout=asyncio.subprocess.PIPE,
151
+ stderr=asyncio.subprocess.DEVNULL,
152
+ )
153
+ except OSError as e:
154
+ log.warning("クリップボードを読めません: %s", e)
155
+ return None
156
+ try:
157
+ out, _ = await asyncio.wait_for(proc.communicate(), _TIMEOUT)
158
+ except asyncio.TimeoutError:
159
+ proc.kill()
160
+ log.warning("クリップボードの読み取りが時間切れになりました")
161
+ return None
162
+ try:
163
+ data = json.loads(out)
164
+ except ValueError as e:
165
+ log.warning("クリップボードの内容を解釈できません: %s", e)
166
+ return None
167
+ return data.get("text"), data.get("html")