logtap 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.
logtap/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """logtap: 跨平台命令行工具箱。
2
+
3
+ 当前提供的子命令:
4
+ logtap read <file> 纯终端小说(.txt)阅读器
5
+ """
6
+
7
+ __version__ = "0.1.0"
logtap/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ """logtap 主命令行入口,负责解析子命令并分发。
2
+
3
+ 当前仅有 read 子命令;后续新增其他 logtap 功能时,在此文件追加新的
4
+ subparser 即可,read 相关实现始终保持在 logtap.read 包内部,不与
5
+ 其他子命令耦合。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from typing import List, Optional
13
+
14
+ from logtap import __version__
15
+
16
+
17
+ def _build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="logtap",
20
+ description="logtap: 跨平台终端工具箱",
21
+ )
22
+ parser.add_argument(
23
+ "--version", action="version", version=f"logtap {__version__}"
24
+ )
25
+ subparsers = parser.add_subparsers(dest="command")
26
+
27
+ read_parser = subparsers.add_parser(
28
+ "read", help="纯终端小说(.txt)阅读器"
29
+ )
30
+ read_parser.add_argument(
31
+ "path",
32
+ nargs="?",
33
+ default=None,
34
+ help="要打开的 .txt 文件路径;留空则在当前目录选择",
35
+ )
36
+
37
+ return parser
38
+
39
+
40
+ def main(argv: Optional[List[str]] = None) -> int:
41
+ parser = _build_parser()
42
+ args = parser.parse_args(argv)
43
+
44
+ if args.command == "read":
45
+ from logtap.read import run as run_reader
46
+
47
+ return run_reader(args.path)
48
+
49
+ parser.print_help()
50
+ return 0
51
+
52
+
53
+ if __name__ == "__main__":
54
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ """logtap.read 子包:纯终端小说阅读器。
2
+
3
+ 对外暴露 run(path) 作为 CLI 层的统一入口。
4
+ """
5
+
6
+ from logtap.read.app import run
7
+
8
+ __all__ = ["run"]
logtap/read/app.py ADDED
@@ -0,0 +1,266 @@
1
+ """Textual 主应用:简化版日志风格小说阅读器。
2
+
3
+ 核心交互:
4
+ - w/s 翻页
5
+ - ↑/↓ 滚动
6
+ - g 跳转行号
7
+ - m 添加/移除书签
8
+ - t 切换主题
9
+ - q 退出保存
10
+ """
11
+
12
+ import os
13
+ import time
14
+ from typing import List, Optional, Set
15
+
16
+ from textual.app import App, ComposeResult
17
+ from textual.binding import Binding
18
+ from textual.containers import VerticalScroll
19
+ from textual.reactive import reactive
20
+ from textual.widgets import Footer
21
+
22
+ from logtap.read import themes
23
+ from logtap.read.log_style_render import LogStyleText
24
+ from logtap.read.reader_core import Document
25
+ from logtap.read.storage import Storage
26
+
27
+
28
+ class NovelReaderApp(App):
29
+ """日志风格小说阅读器 Textual 应用(简化版)。"""
30
+
31
+ CSS = themes.LOG_THEME_CSS
32
+
33
+ BINDINGS = [
34
+ Binding("up,k", "scroll_up", "向上", show=True),
35
+ Binding("down,j", "scroll_down", "向下", show=True),
36
+ Binding("w", "page_up", "上一页", show=True),
37
+ Binding("s", "page_down", "下一页", show=True),
38
+ Binding("g", "goto_line", "跳转", show=True),
39
+ Binding("m", "toggle_bookmark", "书签", show=True),
40
+ Binding("d", "cycle_disguise", "伪装", show=True),
41
+ Binding("t", "cycle_theme", "主题", show=True),
42
+ Binding("q", "quit_app", "退出", show=True),
43
+ ]
44
+
45
+ # 响应式状态
46
+ theme_key: reactive[str] = reactive("log")
47
+ disguise_mode: reactive[str] = reactive("log") # "log" / "ai" / "off"
48
+ current_line: reactive[int] = reactive(0)
49
+
50
+ def __init__(
51
+ self,
52
+ novel_path: str,
53
+ storage: Storage,
54
+ **kwargs,
55
+ ):
56
+ super().__init__(**kwargs)
57
+ self.novel_path = novel_path
58
+ self.storage = storage
59
+ self.document: Optional[Document] = None
60
+ self.text_widget: Optional[LogStyleText] = None
61
+ self.scroll_container: Optional[VerticalScroll] = None
62
+ self.bookmarks: Set[int] = set()
63
+ self._lines: List[tuple] = []
64
+ self._lines_per_page = 20 # 每页显示行数
65
+
66
+ def compose(self) -> ComposeResult:
67
+ """构建 UI 组件树。"""
68
+ with VerticalScroll(id="text-container") as scroll:
69
+ self.scroll_container = scroll
70
+ self.text_widget = LogStyleText(id="log-text")
71
+ yield self.text_widget
72
+ yield Footer()
73
+
74
+ def on_mount(self) -> None:
75
+ """应用启动时加载文档并恢复进度。"""
76
+ try:
77
+ self.document = Document(self.novel_path)
78
+ except Exception as e:
79
+ self.exit(message=f"文件加载失败: {e}")
80
+ return
81
+
82
+ # 恢复进度和书签
83
+ record = self.storage.get_book_record(self.novel_path)
84
+ self.current_line = record.progress.line_index
85
+ self.bookmarks = {bm.line_index for bm in record.bookmarks}
86
+
87
+ # 恢复主题
88
+ saved_theme = self.storage.settings.theme
89
+ if saved_theme in themes.THEME_KEYS:
90
+ self.theme_key = saved_theme
91
+ self.CSS = themes.get_theme_css(saved_theme)
92
+
93
+ # 加载全部文本行
94
+ self._load_text_lines()
95
+ self._render_current_page()
96
+
97
+ # 启动定时更新状态行
98
+ self.set_interval(5.0, self._update_status_line)
99
+
100
+ def _load_text_lines(self) -> None:
101
+ """从 Document 加载所有行,标记类型(章节/正文/空行)。
102
+
103
+ 性能优化:不预加载全部内容到内存,只构建行索引。
104
+ 实际渲染时才调用 document.get_line() 按需读取。
105
+ """
106
+ lines = []
107
+ chapter_starts = {ch.start_line for ch in self.document.chapters}
108
+ # 只构建元数据,不读取实际内容
109
+ for i in range(self.document.line_count):
110
+ if i in chapter_starts:
111
+ lines.append(("chapter", i)) # 存储行号而非内容
112
+ else:
113
+ lines.append(("text", i)) # 存储行号而非内容
114
+ self._lines = lines
115
+
116
+ def _render_current_page(self) -> None:
117
+ """渲染当前页的文本行(按需读取内容,不预加载)。"""
118
+ if not self.text_widget or not self.document:
119
+ return
120
+
121
+ start = max(0, self.current_line)
122
+ end = min(len(self._lines), start + self._lines_per_page)
123
+
124
+ # 按需读取当前页的实际内容
125
+ page_lines = []
126
+ for line_type, line_index in self._lines[start:end]:
127
+ raw_content = self.document.get_line(line_index)
128
+ if line_type == "chapter":
129
+ page_lines.append(("chapter", raw_content))
130
+ elif raw_content.strip() == "":
131
+ page_lines.append(("empty", ""))
132
+ else:
133
+ page_lines.append(("text", raw_content))
134
+
135
+ self.text_widget.set_lines(page_lines)
136
+ self.text_widget.set_bookmarks(self.bookmarks)
137
+ self.text_widget.set_disguise_mode(self.disguise_mode) # 同步伪装模式
138
+ self._update_status_line()
139
+
140
+ def _update_status_line(self) -> None:
141
+ """更新状态栏,显示进度/章节/时间/伪装模式。"""
142
+ if not self.document or not self.text_widget:
143
+ return
144
+ progress = (self.current_line / max(self.document.line_count, 1)) * 100
145
+ chapter = self.document.chapter_at_line(self.current_line)
146
+ chapter_name = chapter.title if chapter else "N/A"
147
+ time_str = time.strftime("%H:%M:%S")
148
+
149
+ # 伪装模式标签
150
+ mode_labels = {"log": "日志模式", "ai": "AI模式", "off": "关闭"}
151
+ mode_label = mode_labels.get(self.disguise_mode, "未知")
152
+
153
+ status = (
154
+ f"Progress: {progress:.1f}% | Chapter: {chapter_name} | "
155
+ f"Line: {self.current_line + 1}/{self.document.line_count} | "
156
+ f"Disguise: {mode_label} | Time: {time_str}"
157
+ )
158
+ self.text_widget.set_status(status)
159
+
160
+ # ----------------------------------------------------------------
161
+ # 快捷键动作
162
+ # ----------------------------------------------------------------
163
+ def action_scroll_up(self) -> None:
164
+ """向上滚动一行。"""
165
+ if self.current_line > 0:
166
+ self.current_line -= 1
167
+ self._render_current_page()
168
+
169
+ def action_scroll_down(self) -> None:
170
+ """向下滚动一行。"""
171
+ if self.current_line < self.document.line_count - 1:
172
+ self.current_line += 1
173
+ self._render_current_page()
174
+
175
+ def action_page_up(self) -> None:
176
+ """向上翻页。"""
177
+ self.current_line = max(0, self.current_line - self._lines_per_page)
178
+ self._render_current_page()
179
+
180
+ def action_page_down(self) -> None:
181
+ """向下翻页。"""
182
+ self.current_line = min(
183
+ self.document.line_count - 1,
184
+ self.current_line + self._lines_per_page
185
+ )
186
+ self._render_current_page()
187
+
188
+ def action_goto_line(self) -> None:
189
+ """跳转到指定行号(简化:直接通知输入提示,不弹窗)。"""
190
+ # 移除复杂的 push_screen 弹窗,改用通知提示用户
191
+ self.notify("跳转功能:输入 'g数字' 如 'g100' 跳转到第100行(开发中)", severity="information")
192
+
193
+ def _do_goto(self, value: str) -> None:
194
+ """执行跳转(保留接口,暂不使用)。"""
195
+ if value.isdigit():
196
+ line_num = int(value) - 1
197
+ if 0 <= line_num < self.document.line_count:
198
+ self.current_line = line_num
199
+ self._render_current_page()
200
+ self.notify(f"已跳转到行 {line_num + 1}")
201
+
202
+ def action_toggle_bookmark(self) -> None:
203
+ """在当前行添加/移除书签。"""
204
+ if self.current_line in self.bookmarks:
205
+ self.bookmarks.remove(self.current_line)
206
+ self.storage.get_book_record(self.novel_path).bookmarks = [
207
+ bm for bm in self.storage.get_book_record(self.novel_path).bookmarks
208
+ if bm.line_index != self.current_line
209
+ ]
210
+ self.storage.save()
211
+ self.notify(f"已移除书签 (行 {self.current_line + 1})")
212
+ else:
213
+ self.bookmarks.add(self.current_line)
214
+ self.storage.add_bookmark(self.novel_path, self.current_line, "")
215
+ self.notify(f"已添加书签 (行 {self.current_line + 1})")
216
+ self._render_current_page()
217
+
218
+ def action_cycle_theme(self) -> None:
219
+ """循环切换主题。"""
220
+ self.theme_key = themes.next_theme_key(self.theme_key)
221
+ new_css = themes.get_theme_css(self.theme_key)
222
+ self.stylesheet.parse(new_css)
223
+ self.storage.set_theme(self.theme_key)
224
+ label = themes.get_theme_label(self.theme_key)
225
+ self.notify(f"主题: {label}")
226
+
227
+ def action_cycle_disguise(self) -> None:
228
+ """循环切换伪装模式:日志 → AI → 关闭 → 日志。"""
229
+ modes = ["log", "ai", "off"]
230
+ try:
231
+ idx = modes.index(self.disguise_mode)
232
+ except ValueError:
233
+ idx = 0
234
+ self.disguise_mode = modes[(idx + 1) % len(modes)]
235
+
236
+ mode_labels = {"log": "日志模式", "ai": "AI助手模式", "off": "关闭伪装"}
237
+ self.notify(f"伪装: {mode_labels[self.disguise_mode]}")
238
+ self._render_current_page() # 重新渲染以应用新模式
239
+
240
+ def action_quit_app(self) -> None:
241
+ """退出并保存进度。"""
242
+ if self.document:
243
+ self.storage.save_progress(
244
+ self.novel_path, self.current_line, self.document.encoding
245
+ )
246
+ self.exit()
247
+
248
+
249
+ def run(path: Optional[str] = None) -> int:
250
+ """logtap read 子命令入口(Textual 版本)。"""
251
+ if path is None:
252
+ print("请指定要打开的 .txt 文件路径")
253
+ return 1
254
+
255
+ if not os.path.exists(path):
256
+ print(f"文件不存在: {path}")
257
+ return 1
258
+
259
+ if not path.lower().endswith(".txt"):
260
+ print("仅支持 .txt 文本文件")
261
+ return 1
262
+
263
+ storage = Storage()
264
+ app = NovelReaderApp(novel_path=os.path.abspath(path), storage=storage)
265
+ app.run()
266
+ return 0
@@ -0,0 +1,141 @@
1
+ """伪装模式渲染逻辑:日志模式 / AI助手模式。
2
+
3
+ 核心设计:
4
+ - 日志模式:强制分段 + 每行时间戳 + 日志级别 + 高频噪音(40%)
5
+ - AI模式:连续thinking/diff块 + 段落化输出 + 高频噪音(50%)
6
+ - 真实感优先:模拟实际工作场景的输出特征
7
+ """
8
+
9
+ import random
10
+ import re
11
+ import time
12
+ from typing import List, Tuple
13
+
14
+ # 模拟系统日志噪音库(日志模式)
15
+ LOG_NOISE_TEMPLATES = [
16
+ "[INFO] Database connection pool: 8/20 active",
17
+ "[DEBUG] Cache hit ratio: {:.1f}%",
18
+ "[INFO] Request processed in {:.2f}ms",
19
+ "[WARN] Memory usage: {}MB / 2048MB",
20
+ "[DEBUG] Session cleanup completed",
21
+ "[INFO] Background task scheduler running",
22
+ "[DEBUG] API rate limit: {}/1000 requests",
23
+ "[INFO] Health check passed",
24
+ "[WARN] Slow query detected: {}ms",
25
+ "[DEBUG] Connection from {}",
26
+ ]
27
+
28
+ # 模拟AI思考/状态噪音库(AI模式)
29
+ AI_NOISE_TEMPLATES = [
30
+ "<thinking>分析需求中...</thinking>",
31
+ "<thinking>检查代码结构...</thinking>",
32
+ "# 生成实现方案",
33
+ "# 验证逻辑正确性",
34
+ "[assistant] 好的,我来帮你实现这个功能。",
35
+ "[user] 这段代码有什么问题?",
36
+ "```diff",
37
+ "// 重构后的实现",
38
+ "/* 优化性能 */",
39
+ ]
40
+
41
+ # 随机IP地址池
42
+ IP_POOL = ["192.168.1.{}".format(i) for i in range(10, 50)]
43
+
44
+
45
+ def generate_timestamp() -> str:
46
+ """生成当前时间戳(日志格式)。"""
47
+ return time.strftime("%Y-%m-%d %H:%M:%S")
48
+
49
+
50
+ def generate_log_noise() -> str:
51
+ """生成一条随机系统日志噪音。"""
52
+ template = random.choice(LOG_NOISE_TEMPLATES)
53
+ if "{}" in template or "{:" in template:
54
+ # 填充随机数据
55
+ if "Cache hit" in template:
56
+ return template.format(random.uniform(75.0, 95.0))
57
+ elif "processed in" in template:
58
+ return template.format(random.uniform(5.0, 50.0))
59
+ elif "Memory usage" in template:
60
+ return template.format(random.randint(512, 1800))
61
+ elif "rate limit" in template:
62
+ return template.format(random.randint(100, 800))
63
+ elif "Slow query" in template:
64
+ return template.format(random.randint(200, 800))
65
+ elif "Connection from" in template:
66
+ return template.format(random.choice(IP_POOL))
67
+ return template
68
+
69
+
70
+ def generate_ai_noise() -> str:
71
+ """生成一条随机AI助手状态/思考噪音。"""
72
+ return random.choice(AI_NOISE_TEMPLATES)
73
+
74
+
75
+ def should_insert_noise(probability: float = 0.4) -> bool:
76
+ """根据概率决定是否插入噪音行(提升到40%)。"""
77
+ return random.random() < probability
78
+
79
+
80
+ def should_add_timestamp(probability: float = 1.0) -> bool:
81
+ """根据概率决定是否给正文行加时间戳(日志模式必加)。"""
82
+ return random.random() < probability
83
+
84
+
85
+ def should_render_as_diff(probability: float = 0.5) -> bool:
86
+ """根据概率决定是否将正文渲染成diff格式(AI模式提升到50%)。"""
87
+ return random.random() < probability
88
+
89
+
90
+ def get_random_log_level() -> Tuple[str, str]:
91
+ """随机返回一个日志级别标签及其颜色样式。
92
+
93
+ 返回: (标签, Rich样式)
94
+ """
95
+ levels = [
96
+ ("[INFO]", "blue"),
97
+ ("[DEBUG]", "dim cyan"),
98
+ ("[WARN]", "yellow"),
99
+ ("[ERROR]", "red"),
100
+ ]
101
+ weights = [0.5, 0.3, 0.15, 0.05] # INFO最常见,ERROR最少
102
+ choice = random.choices(levels, weights=weights, k=1)[0]
103
+ return choice
104
+
105
+
106
+ def wrap_as_diff_line(content: str, is_add: bool) -> Tuple[str, str]:
107
+ """将正文包装成diff格式。
108
+
109
+ Args:
110
+ content: 原始文本
111
+ is_add: True=新增行(+绿色), False=删除行(-红色)
112
+
113
+ Returns:
114
+ (prefix, style) 例如 ("+ ", "green") 或 ("- ", "red")
115
+ """
116
+ if is_add:
117
+ return ("+ ", "green")
118
+ else:
119
+ return ("- ", "red")
120
+
121
+
122
+ def insert_ai_thinking_block(lines: List[Tuple[str, str]], index: int) -> None:
123
+ """在指定位置插入一条AI思考链路噪音。
124
+
125
+ Args:
126
+ lines: 待渲染的行列表 [(line_type, content), ...]
127
+ index: 插入位置
128
+ """
129
+ thinking = generate_ai_noise()
130
+ lines.insert(index, ("ai_noise", thinking))
131
+
132
+
133
+ def insert_log_noise_line(lines: List[Tuple[str, str]], index: int) -> None:
134
+ """在指定位置插入一条系统日志噪音。
135
+
136
+ Args:
137
+ lines: 待渲染的行列表 [(line_type, content), ...]
138
+ index: 插入位置
139
+ """
140
+ log_line = generate_log_noise()
141
+ lines.insert(index, ("log_noise", log_line))