seejx 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.
- seejx/__init__.py +20 -0
- seejx/conditions.py +178 -0
- seejx/config.py +82 -0
- seejx/navigator.py +419 -0
- seejx/plugins/__init__.py +13 -0
- seejx/plugins/loader.py +136 -0
- seejx/renderers/__init__.py +30 -0
- seejx/renderers/base.py +128 -0
- seejx/renderers/default.py +16 -0
- seejx/renderers/messages.py +317 -0
- seejx/renderers/text.py +40 -0
- seejx/see_jsonl.py +114 -0
- seejx/tools.py +74 -0
- seejx-0.3.0.dist-info/METADATA +112 -0
- seejx-0.3.0.dist-info/RECORD +20 -0
- seejx-0.3.0.dist-info/WHEEL +5 -0
- seejx-0.3.0.dist-info/entry_points.txt +2 -0
- seejx-0.3.0.dist-info/licenses/LICENSE +22 -0
- seejx-0.3.0.dist-info/licenses/NOTICE +28 -0
- seejx-0.3.0.dist-info/top_level.txt +1 -0
seejx/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""seejx - Interactive JSONL browser with rich rendering and full-screen scrolling.
|
|
2
|
+
|
|
3
|
+
Fork of `seej` (MIT, © 2025 Lei Zhang) with an in-app full-screen scroll
|
|
4
|
+
viewer: trackpad / mouse-wheel / arrow-key scrolling, wcwidth-aware wrapping,
|
|
5
|
+
and a soft status bar. See NOTICE for details.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.3.0"
|
|
9
|
+
|
|
10
|
+
from .see_jsonl import main
|
|
11
|
+
from .renderers import register_renderer, get_renderer
|
|
12
|
+
from .conditions import SafeExampleProxy, build_condition_function
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"main",
|
|
16
|
+
"register_renderer",
|
|
17
|
+
"get_renderer",
|
|
18
|
+
"SafeExampleProxy",
|
|
19
|
+
"build_condition_function",
|
|
20
|
+
]
|
seejx/conditions.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Condition parsing and evaluation for filtering examples."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import operator
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SafeExampleProxy:
|
|
12
|
+
"""安全的样本代理,支持 ex['key'] 和 ex.key 两种语法"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, data: dict):
|
|
15
|
+
self._data = data
|
|
16
|
+
|
|
17
|
+
def __getitem__(self, key):
|
|
18
|
+
"""支持 ex['key'] 语法"""
|
|
19
|
+
return self._data.get(key)
|
|
20
|
+
|
|
21
|
+
def __getattr__(self, key):
|
|
22
|
+
"""支持 ex.key 语法"""
|
|
23
|
+
if key.startswith("_"):
|
|
24
|
+
raise AttributeError(f"Cannot access private attribute: {key}")
|
|
25
|
+
return self._data.get(key)
|
|
26
|
+
|
|
27
|
+
def __contains__(self, key):
|
|
28
|
+
"""支持 'key' in ex"""
|
|
29
|
+
return key in self._data
|
|
30
|
+
|
|
31
|
+
def get(self, key, default=None):
|
|
32
|
+
"""支持 ex.get('key', default)"""
|
|
33
|
+
return self._data.get(key, default)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_safe_namespace():
|
|
37
|
+
"""创建安全的执行命名空间"""
|
|
38
|
+
# 只允许安全的内置函数和操作符
|
|
39
|
+
safe_builtins = {
|
|
40
|
+
"len": len,
|
|
41
|
+
"str": str,
|
|
42
|
+
"int": int,
|
|
43
|
+
"float": float,
|
|
44
|
+
"bool": bool,
|
|
45
|
+
"list": list,
|
|
46
|
+
"dict": dict,
|
|
47
|
+
"tuple": tuple,
|
|
48
|
+
"set": set,
|
|
49
|
+
"abs": abs,
|
|
50
|
+
"min": min,
|
|
51
|
+
"max": max,
|
|
52
|
+
"sum": sum,
|
|
53
|
+
"any": any,
|
|
54
|
+
"all": all,
|
|
55
|
+
"isinstance": isinstance,
|
|
56
|
+
"True": True,
|
|
57
|
+
"False": False,
|
|
58
|
+
"None": None,
|
|
59
|
+
}
|
|
60
|
+
return safe_builtins
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_condition_syntax(condition: str) -> tuple[bool, Optional[str]]:
|
|
64
|
+
"""
|
|
65
|
+
验证条件表达式的语法安全性
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
(is_valid, error_message)
|
|
69
|
+
"""
|
|
70
|
+
# 检查是否包含 'ex'
|
|
71
|
+
if "ex" not in condition:
|
|
72
|
+
return False, "Condition must reference 'ex' (e.g., ex['field'] == 'value')"
|
|
73
|
+
|
|
74
|
+
# 尝试解析 AST
|
|
75
|
+
try:
|
|
76
|
+
tree = ast.parse(condition, mode="eval")
|
|
77
|
+
except SyntaxError as e:
|
|
78
|
+
return False, f"Syntax error: {e}"
|
|
79
|
+
|
|
80
|
+
# 检查危险操作
|
|
81
|
+
dangerous_nodes = (
|
|
82
|
+
ast.Import,
|
|
83
|
+
ast.ImportFrom, # 禁止导入
|
|
84
|
+
ast.Call, # 暂时禁止所有函数调用(可以放宽)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for node in ast.walk(tree):
|
|
88
|
+
# 允许安全的函数调用
|
|
89
|
+
if isinstance(node, ast.Call):
|
|
90
|
+
if isinstance(node.func, ast.Name):
|
|
91
|
+
func_name = node.func.id
|
|
92
|
+
if func_name not in create_safe_namespace():
|
|
93
|
+
return False, f"Function '{func_name}' is not allowed"
|
|
94
|
+
|
|
95
|
+
# 禁止其他危险操作
|
|
96
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
97
|
+
return False, "Import statements are not allowed"
|
|
98
|
+
|
|
99
|
+
return True, None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_condition_function(conditions: list[str]) -> Optional[Callable]:
|
|
103
|
+
"""
|
|
104
|
+
构建条件过滤函数
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
conditions: 条件表达式列表,例如 ["ex['type'] == 'chat'", "len(ex['messages']) > 5"]
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
过滤函数或 None
|
|
111
|
+
"""
|
|
112
|
+
if not conditions:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
console.print("[bold]Building filter conditions:[/bold]")
|
|
116
|
+
|
|
117
|
+
# 验证并处理每个条件
|
|
118
|
+
validated_conditions = []
|
|
119
|
+
for idx, cond in enumerate(conditions, 1):
|
|
120
|
+
cond = cond.strip()
|
|
121
|
+
if not cond:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
is_valid, error = validate_condition_syntax(cond)
|
|
125
|
+
if not is_valid:
|
|
126
|
+
console.print(f"[red]✗[/red] Condition {idx}: {cond}")
|
|
127
|
+
console.print(f" [red]Error: {error}[/red]")
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
validated_conditions.append(cond)
|
|
131
|
+
console.print(f"[green]✓[/green] Condition {idx}: [cyan]{cond}[/cyan]")
|
|
132
|
+
|
|
133
|
+
if not validated_conditions:
|
|
134
|
+
console.print("[yellow]Warning: No valid conditions, showing all examples[/yellow]")
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
# 组合所有条件
|
|
138
|
+
combined_expr = " and ".join(f"({c})" for c in validated_conditions)
|
|
139
|
+
console.print(f"\n[bold]Combined:[/bold] [dim]{combined_expr}[/dim]\n")
|
|
140
|
+
|
|
141
|
+
# 编译表达式
|
|
142
|
+
try:
|
|
143
|
+
compiled_code = compile(combined_expr, "<condition>", "eval")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
console.print(f"[red]Failed to compile conditions: {e}[/red]")
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
# 创建安全的命名空间
|
|
149
|
+
safe_namespace = create_safe_namespace()
|
|
150
|
+
|
|
151
|
+
def condition_filter(example: dict) -> bool:
|
|
152
|
+
"""实际的过滤函数"""
|
|
153
|
+
try:
|
|
154
|
+
# 创建代理对象
|
|
155
|
+
ex = SafeExampleProxy(example)
|
|
156
|
+
# 在安全命名空间中执行
|
|
157
|
+
namespace = {**safe_namespace, "ex": ex}
|
|
158
|
+
result = eval(compiled_code, {"__builtins__": {}}, namespace)
|
|
159
|
+
return bool(result)
|
|
160
|
+
except KeyError as e:
|
|
161
|
+
# 字段不存在时返回 False
|
|
162
|
+
return False
|
|
163
|
+
except Exception as e:
|
|
164
|
+
# 其他错误:打印警告但不中断
|
|
165
|
+
# console.print(f"[yellow]Warning: Condition evaluation error: {e}[/yellow]")
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
console.print(f"\n[red][bold]>>>Press any key to see jsonl!<<<\n[/bold][/red]")
|
|
170
|
+
input()
|
|
171
|
+
except KeyboardInterrupt:
|
|
172
|
+
console.print(f"\n[bold]Keyboard Interruptted[/bold]\n")
|
|
173
|
+
exit(0)
|
|
174
|
+
|
|
175
|
+
return condition_filter
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ============ 向后兼容的旧版解析(可选保留) ============
|
seejx/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Configuration parsing for seejx."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Callable
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from .renderers.base import get_renderer, list_renderers, get_all_renderer_names
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_render_mappings(render_args) -> Dict[str, Callable]:
|
|
12
|
+
"""解析渲染映射参数"""
|
|
13
|
+
field_mapper = {}
|
|
14
|
+
if not render_args:
|
|
15
|
+
return field_mapper
|
|
16
|
+
|
|
17
|
+
for mapping in render_args:
|
|
18
|
+
if "=" not in mapping:
|
|
19
|
+
console.print(f"[yellow]Warning: Invalid mapping format '{mapping}', expected 'renderer=field1,field2'[/yellow]")
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
renderer_name, fields_str = mapping.split("=", 1)
|
|
23
|
+
renderer = get_renderer(renderer_name.strip())
|
|
24
|
+
|
|
25
|
+
if renderer is None:
|
|
26
|
+
console.print(f"[yellow]Warning: Unknown renderer '{renderer_name}'[/yellow]")
|
|
27
|
+
|
|
28
|
+
# 尝试给出建议
|
|
29
|
+
all_names = get_all_renderer_names()
|
|
30
|
+
suggestions = [name for name in all_names if renderer_name.lower() in name.lower()]
|
|
31
|
+
if suggestions:
|
|
32
|
+
console.print(f"[dim]Did you mean: {', '.join(suggestions[:3])}?[/dim]")
|
|
33
|
+
console.print(f"[dim]Use --list-renderers to see all available options[/dim]")
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
for field in fields_str.split(","):
|
|
37
|
+
field = field.strip()
|
|
38
|
+
if field:
|
|
39
|
+
field_mapper[field] = renderer
|
|
40
|
+
console.print(f"[green]✓[/green] Mapped '{field}' → {renderer_name}")
|
|
41
|
+
|
|
42
|
+
return field_mapper
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def print_available_renderers():
|
|
46
|
+
"""打印所有可用的渲染器"""
|
|
47
|
+
renderers = list_renderers()
|
|
48
|
+
|
|
49
|
+
if not renderers:
|
|
50
|
+
console.print("[yellow]No renderers available[/yellow]")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
table = Table(title="Available Renderers", show_header=True, header_style="bold magenta")
|
|
54
|
+
table.add_column("Renderer", style="cyan", no_wrap=True)
|
|
55
|
+
table.add_column("Source", style="yellow", width=12)
|
|
56
|
+
table.add_column("Aliases", style="dim")
|
|
57
|
+
table.add_column("Description", style="green")
|
|
58
|
+
|
|
59
|
+
# 按来源分组排序
|
|
60
|
+
builtin_renderers = []
|
|
61
|
+
plugin_renderers = []
|
|
62
|
+
|
|
63
|
+
for name, info in renderers.items():
|
|
64
|
+
if info.source == "builtin":
|
|
65
|
+
builtin_renderers.append((name, info))
|
|
66
|
+
else:
|
|
67
|
+
plugin_renderers.append((name, info))
|
|
68
|
+
|
|
69
|
+
# 先显示内置,再显示插件
|
|
70
|
+
for name, info in sorted(builtin_renderers) + sorted(plugin_renderers):
|
|
71
|
+
source = info.source
|
|
72
|
+
if info.plugin_name:
|
|
73
|
+
source = f"📦 {info.plugin_name}"
|
|
74
|
+
|
|
75
|
+
aliases = ", ".join(info.aliases) if info.aliases else "-"
|
|
76
|
+
desc = info.description or "-"
|
|
77
|
+
|
|
78
|
+
table.add_row(name, source, aliases, desc)
|
|
79
|
+
|
|
80
|
+
console.print(table)
|
|
81
|
+
console.print("\n[bold]Usage:[/bold] seejx data.jsonl -r [cyan]<renderer>[/cyan]=[green]<field1>[/green],[green]<field2>[/green]...")
|
|
82
|
+
console.print("[bold]Example:[/bold] seejx data.jsonl -r messages=chat,conversation")
|
seejx/navigator.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Example navigator for browsing JSONL data.
|
|
2
|
+
|
|
3
|
+
内建全屏滚动视图:整条记录常驻内存,只显示一个窗口。
|
|
4
|
+
- 两指滑动 / 鼠标滚轮 / ↑↓ / j k :上下滚动(永远能到最顶最底)
|
|
5
|
+
- PgUp/PgDn / 空格 / b :整页滚动
|
|
6
|
+
- g / G :跳到开头 / 结尾
|
|
7
|
+
- → / ←(或 n / p / 回车) :下一条 / 上一条记录
|
|
8
|
+
- t :切换 rich / raw 渲染
|
|
9
|
+
- q(或 Ctrl-C) :退出
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import shutil
|
|
16
|
+
import termios
|
|
17
|
+
import tty
|
|
18
|
+
import select
|
|
19
|
+
from contextlib import redirect_stdout
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import readchar # noqa: F401 (保留以兼容其它模块)
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from .tools import get_terminal_width, load_jsonl_iter
|
|
28
|
+
from .renderers.base import get_renderer
|
|
29
|
+
from .renderers.base import console as _base_console
|
|
30
|
+
from .renderers import console
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from wcwidth import wcwidth as _wcwidth
|
|
34
|
+
|
|
35
|
+
def _cw(c):
|
|
36
|
+
w = _wcwidth(c)
|
|
37
|
+
return 1 if w < 0 else w
|
|
38
|
+
except Exception: # pragma: no cover
|
|
39
|
+
|
|
40
|
+
def _cw(c):
|
|
41
|
+
return 2 if ord(c) >= 0x1100 else 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
console = Console()
|
|
45
|
+
|
|
46
|
+
ESC = "\x1b"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def control_str(s, wrap=False, raw=False):
|
|
50
|
+
"""打印控制信息(保留给非交互路径使用)"""
|
|
51
|
+
if raw:
|
|
52
|
+
console.print(s)
|
|
53
|
+
return
|
|
54
|
+
content = Text(s, style="bold red", overflow="fold")
|
|
55
|
+
if wrap:
|
|
56
|
+
content = Panel(content, expand=False, title="Control")
|
|
57
|
+
console.print(content)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _wrap_ansi(line, width):
|
|
61
|
+
"""按可见宽度硬换行,保留 ANSI 颜色(跨行续色)。"""
|
|
62
|
+
if width <= 0:
|
|
63
|
+
return [line]
|
|
64
|
+
line = line.rstrip("\r")
|
|
65
|
+
rows = []
|
|
66
|
+
cur = ""
|
|
67
|
+
vis = 0
|
|
68
|
+
active = [] # 当前生效的 SGR 序列,换行时续上
|
|
69
|
+
i, n = 0, len(line)
|
|
70
|
+
|
|
71
|
+
def flush(newline_active):
|
|
72
|
+
nonlocal cur, vis
|
|
73
|
+
rows.append(cur + (ESC + "[0m" if active else ""))
|
|
74
|
+
cur = "".join(active) if newline_active else ""
|
|
75
|
+
vis = 0
|
|
76
|
+
|
|
77
|
+
while i < n:
|
|
78
|
+
c = line[i]
|
|
79
|
+
if c == ESC and i + 1 < n and line[i + 1] == "[":
|
|
80
|
+
j = i + 2
|
|
81
|
+
while j < n and not ("@" <= line[j] <= "~"):
|
|
82
|
+
j += 1
|
|
83
|
+
seq = line[i : j + 1]
|
|
84
|
+
cur += seq
|
|
85
|
+
if seq.endswith("m"):
|
|
86
|
+
params = seq[2:-1]
|
|
87
|
+
if params in ("", "0"):
|
|
88
|
+
active = []
|
|
89
|
+
else:
|
|
90
|
+
active.append(seq)
|
|
91
|
+
i = j + 1
|
|
92
|
+
continue
|
|
93
|
+
if c == "\t":
|
|
94
|
+
spaces = 8 - (vis % 8)
|
|
95
|
+
for _ in range(spaces):
|
|
96
|
+
if vis >= width:
|
|
97
|
+
flush(True)
|
|
98
|
+
cur += " "
|
|
99
|
+
vis += 1
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
cwid = _cw(c)
|
|
103
|
+
if vis + cwid > width:
|
|
104
|
+
flush(True)
|
|
105
|
+
cur += c
|
|
106
|
+
vis += cwid
|
|
107
|
+
i += 1
|
|
108
|
+
rows.append(cur + (ESC + "[0m" if active else ""))
|
|
109
|
+
return rows
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ExampleNavigator:
|
|
113
|
+
"""JSONL 示例浏览器(全屏滚动版)"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
file_path,
|
|
118
|
+
start_idx=0,
|
|
119
|
+
no_rich=False,
|
|
120
|
+
condition_fn=None,
|
|
121
|
+
condition_display=None,
|
|
122
|
+
show_tool_call_details=False,
|
|
123
|
+
custom_mapper=None,
|
|
124
|
+
):
|
|
125
|
+
self.file_path = file_path
|
|
126
|
+
self.start_idx = start_idx
|
|
127
|
+
self.no_rich = no_rich
|
|
128
|
+
self.condition_fn = condition_fn
|
|
129
|
+
self.condition_display = condition_display or "None"
|
|
130
|
+
self.custom_mapper = custom_mapper or {}
|
|
131
|
+
|
|
132
|
+
self._render_configs = {
|
|
133
|
+
"show_tool_call_details": show_tool_call_details,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
self.current_idx = -1
|
|
137
|
+
self.iterator = load_jsonl_iter(file_path, skip=start_idx)
|
|
138
|
+
self.example_history = []
|
|
139
|
+
|
|
140
|
+
self.top = 0
|
|
141
|
+
self.status = ""
|
|
142
|
+
self._cache_key = None
|
|
143
|
+
self._wrapped = []
|
|
144
|
+
|
|
145
|
+
if not self._advance("next"):
|
|
146
|
+
print("No examples matched.")
|
|
147
|
+
return
|
|
148
|
+
self.run()
|
|
149
|
+
|
|
150
|
+
# ---------- 渲染 ----------
|
|
151
|
+
|
|
152
|
+
def get_renderer_for_field(self, field_name):
|
|
153
|
+
if field_name in self.custom_mapper:
|
|
154
|
+
return self.custom_mapper[field_name]
|
|
155
|
+
renderer = get_renderer(field_name)
|
|
156
|
+
if renderer is not None:
|
|
157
|
+
return renderer
|
|
158
|
+
return get_renderer("eye")
|
|
159
|
+
|
|
160
|
+
def _render_current_lines(self, cols):
|
|
161
|
+
"""把当前记录渲染成带颜色的逻辑行列表。"""
|
|
162
|
+
file_idx, ex = self.example_history[self.current_idx]
|
|
163
|
+
consoles = [console, _base_console]
|
|
164
|
+
saved = [(c._force_terminal, c._width) for c in consoles]
|
|
165
|
+
for c in consoles:
|
|
166
|
+
c._force_terminal = True
|
|
167
|
+
c._width = cols
|
|
168
|
+
buf = io.StringIO()
|
|
169
|
+
try:
|
|
170
|
+
with redirect_stdout(buf):
|
|
171
|
+
console.rule(f"[bold cyan]▼ IDX={file_idx}[/bold cyan]", style="cyan")
|
|
172
|
+
for k, v in ex.items():
|
|
173
|
+
renderer = self.get_renderer_for_field(k)
|
|
174
|
+
console.print(
|
|
175
|
+
f"[[red][bold]{k}[/bold][/red] [dim]@{renderer.__name__}[/dim]]"
|
|
176
|
+
)
|
|
177
|
+
renderer(v, no_rich=self.no_rich, **self._render_configs)
|
|
178
|
+
print("-" * cols)
|
|
179
|
+
finally:
|
|
180
|
+
for c, (ft, w) in zip(consoles, saved):
|
|
181
|
+
c._force_terminal = ft
|
|
182
|
+
c._width = w
|
|
183
|
+
return buf.getvalue().split("\n")
|
|
184
|
+
|
|
185
|
+
def _get_wrapped(self, cols):
|
|
186
|
+
key = (cols, self.current_idx, self.no_rich)
|
|
187
|
+
if key != self._cache_key:
|
|
188
|
+
logical = self._render_current_lines(cols)
|
|
189
|
+
wrapped = []
|
|
190
|
+
for ln in logical:
|
|
191
|
+
wrapped.extend(_wrap_ansi(ln, cols))
|
|
192
|
+
self._wrapped = wrapped
|
|
193
|
+
self._cache_key = key
|
|
194
|
+
return self._wrapped
|
|
195
|
+
|
|
196
|
+
def _draw(self, wrapped, cols, rows, view_h, max_top):
|
|
197
|
+
out = [ESC + "[H"]
|
|
198
|
+
for i in range(view_h):
|
|
199
|
+
idx = self.top + i
|
|
200
|
+
row = wrapped[idx] if idx < len(wrapped) else ""
|
|
201
|
+
out.append(f"{ESC}[{i + 1};1H{ESC}[2K" + row)
|
|
202
|
+
total = len(wrapped)
|
|
203
|
+
bot = min(total, self.top + view_h)
|
|
204
|
+
pct = 100 if max_top == 0 else int(self.top / max_top * 100)
|
|
205
|
+
file_idx, _ = self.example_history[self.current_idx]
|
|
206
|
+
mode = "raw" if self.no_rich else "rich"
|
|
207
|
+
|
|
208
|
+
# 柔和状态条:深灰底 + 分色前景(不再用刺眼的反显白底黑字)
|
|
209
|
+
BG = f"{ESC}[48;5;236m"
|
|
210
|
+
segs = []
|
|
211
|
+
vis = 0
|
|
212
|
+
|
|
213
|
+
def add(text, fg):
|
|
214
|
+
"""按剩余宽度截断后着色追加,保证整条不超过一行。"""
|
|
215
|
+
nonlocal vis
|
|
216
|
+
room = cols - vis
|
|
217
|
+
if room <= 0:
|
|
218
|
+
return
|
|
219
|
+
out_chars = []
|
|
220
|
+
for ch in text:
|
|
221
|
+
w = _cw(ch)
|
|
222
|
+
if w > room:
|
|
223
|
+
break
|
|
224
|
+
out_chars.append(ch)
|
|
225
|
+
room -= w
|
|
226
|
+
vis += w
|
|
227
|
+
if out_chars:
|
|
228
|
+
segs.append(f"{ESC}[38;5;{fg}m" + "".join(out_chars))
|
|
229
|
+
|
|
230
|
+
add(f" IDX={file_idx} ", 81) # 青色
|
|
231
|
+
add(f" {self.top + 1}-{bot}/{total} {pct:3d}% ", 252) # 浅灰
|
|
232
|
+
add(f" [{mode}] ", 179) # 暖黄
|
|
233
|
+
add(" ↕滚动 ←→上/下条 g/G首尾 t切换 q退出 ", 244) # 暗灰提示
|
|
234
|
+
if self.status:
|
|
235
|
+
add(f" {self.status} ", 210) # 浅红提醒
|
|
236
|
+
if vis < cols:
|
|
237
|
+
segs.append(" " * (cols - vis)) # 用底色补齐整行
|
|
238
|
+
bar = BG + "".join(segs) + ESC + "[0m"
|
|
239
|
+
out.append(f"{ESC}[{rows};1H{ESC}[2K" + bar)
|
|
240
|
+
|
|
241
|
+
sys.stdout.write("".join(out))
|
|
242
|
+
sys.stdout.flush()
|
|
243
|
+
|
|
244
|
+
# ---------- 记录导航 ----------
|
|
245
|
+
|
|
246
|
+
def _advance(self, direction):
|
|
247
|
+
if direction == "prev":
|
|
248
|
+
if self.current_idx > 0:
|
|
249
|
+
self.current_idx -= 1
|
|
250
|
+
self.status = ""
|
|
251
|
+
return True
|
|
252
|
+
self.status = "已是第一条"
|
|
253
|
+
return False
|
|
254
|
+
# next
|
|
255
|
+
if self.current_idx < len(self.example_history) - 1:
|
|
256
|
+
self.current_idx += 1
|
|
257
|
+
self.status = ""
|
|
258
|
+
return True
|
|
259
|
+
try:
|
|
260
|
+
with open(os.devnull, "w") as dn, redirect_stdout(dn):
|
|
261
|
+
while True:
|
|
262
|
+
new_example = next(self.iterator)
|
|
263
|
+
if self.condition_fn is None or self.condition_fn(new_example):
|
|
264
|
+
break
|
|
265
|
+
self.current_idx += 1
|
|
266
|
+
self.example_history.append((self.current_idx + self.start_idx, new_example))
|
|
267
|
+
self.status = ""
|
|
268
|
+
return True
|
|
269
|
+
except StopIteration:
|
|
270
|
+
self.status = "已到最后一条"
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
# ---------- 输入 ----------
|
|
274
|
+
|
|
275
|
+
def _read_event(self):
|
|
276
|
+
fd = self.fd
|
|
277
|
+
|
|
278
|
+
def more(timeout=0.03):
|
|
279
|
+
r, _, _ = select.select([fd], [], [], timeout)
|
|
280
|
+
return os.read(fd, 1) if r else b""
|
|
281
|
+
|
|
282
|
+
ch = os.read(fd, 1)
|
|
283
|
+
if not ch or ch == b"\x03":
|
|
284
|
+
return "quit"
|
|
285
|
+
if ch in (b"q", b"Q", b"x", b"X"):
|
|
286
|
+
return "quit"
|
|
287
|
+
if ch in (b"t", b"T"):
|
|
288
|
+
return "toggle"
|
|
289
|
+
if ch == b" ":
|
|
290
|
+
return "pgdn"
|
|
291
|
+
if ch in (b"b", b"B"):
|
|
292
|
+
return "pgup"
|
|
293
|
+
if ch == b"g":
|
|
294
|
+
return "home"
|
|
295
|
+
if ch == b"G":
|
|
296
|
+
return "end"
|
|
297
|
+
if ch in (b"j",):
|
|
298
|
+
return "down"
|
|
299
|
+
if ch in (b"k",):
|
|
300
|
+
return "up"
|
|
301
|
+
if ch in (b"n", b"N", b"\r", b"\n"):
|
|
302
|
+
return "nextrec"
|
|
303
|
+
if ch in (b"p", b"P"):
|
|
304
|
+
return "prevrec"
|
|
305
|
+
if ch != b"\x1b":
|
|
306
|
+
return "none"
|
|
307
|
+
|
|
308
|
+
b1 = more()
|
|
309
|
+
if b1 == b"":
|
|
310
|
+
return "quit" # 单独 ESC
|
|
311
|
+
if b1 not in (b"[", b"O"):
|
|
312
|
+
return "none"
|
|
313
|
+
nb = more()
|
|
314
|
+
if nb == b"<": # SGR 鼠标/滚轮
|
|
315
|
+
data = b""
|
|
316
|
+
while True:
|
|
317
|
+
c = more()
|
|
318
|
+
if not c or c in (b"M", b"m"):
|
|
319
|
+
break
|
|
320
|
+
data += c
|
|
321
|
+
try:
|
|
322
|
+
cb = int(data.split(b";")[0])
|
|
323
|
+
except (ValueError, IndexError):
|
|
324
|
+
return "none"
|
|
325
|
+
if cb == 64:
|
|
326
|
+
return "wheelup"
|
|
327
|
+
if cb == 65:
|
|
328
|
+
return "wheeldown"
|
|
329
|
+
return "none"
|
|
330
|
+
seq = nb
|
|
331
|
+
while seq[-1:].isdigit() or seq[-1:] == b";":
|
|
332
|
+
c = more()
|
|
333
|
+
if not c:
|
|
334
|
+
break
|
|
335
|
+
seq += c
|
|
336
|
+
final = seq[-1:]
|
|
337
|
+
if final == b"A":
|
|
338
|
+
return "up"
|
|
339
|
+
if final == b"B":
|
|
340
|
+
return "down"
|
|
341
|
+
if final == b"C":
|
|
342
|
+
return "nextrec"
|
|
343
|
+
if final == b"D":
|
|
344
|
+
return "prevrec"
|
|
345
|
+
if final == b"H":
|
|
346
|
+
return "home"
|
|
347
|
+
if final == b"F":
|
|
348
|
+
return "end"
|
|
349
|
+
if final == b"~":
|
|
350
|
+
n = seq[:-1]
|
|
351
|
+
if n == b"5":
|
|
352
|
+
return "pgup"
|
|
353
|
+
if n == b"6":
|
|
354
|
+
return "pgdn"
|
|
355
|
+
if n in (b"1", b"7"):
|
|
356
|
+
return "home"
|
|
357
|
+
if n in (b"4", b"8"):
|
|
358
|
+
return "end"
|
|
359
|
+
return "none"
|
|
360
|
+
|
|
361
|
+
# ---------- 主循环 ----------
|
|
362
|
+
|
|
363
|
+
def run(self):
|
|
364
|
+
self.fd = sys.stdin.fileno()
|
|
365
|
+
try:
|
|
366
|
+
old = termios.tcgetattr(self.fd)
|
|
367
|
+
except termios.error:
|
|
368
|
+
# 非 tty(如管道):退回一次性打印
|
|
369
|
+
cols = get_terminal_width()
|
|
370
|
+
for ln in self._get_wrapped(cols):
|
|
371
|
+
print(ln)
|
|
372
|
+
return
|
|
373
|
+
try:
|
|
374
|
+
tty.setcbreak(self.fd)
|
|
375
|
+
# 备用屏 + 隐藏光标 + 开启鼠标滚轮上报(SGR)
|
|
376
|
+
sys.stdout.write(ESC + "[?1049h" + ESC + "[?25l" + ESC + "[?1000h" + ESC + "[?1006h")
|
|
377
|
+
sys.stdout.flush()
|
|
378
|
+
while True:
|
|
379
|
+
cols, rows = shutil.get_terminal_size((80, 24))
|
|
380
|
+
wrapped = self._get_wrapped(cols)
|
|
381
|
+
view_h = max(1, rows - 1)
|
|
382
|
+
max_top = max(0, len(wrapped) - view_h)
|
|
383
|
+
self.top = max(0, min(self.top, max_top))
|
|
384
|
+
self._draw(wrapped, cols, rows, view_h, max_top)
|
|
385
|
+
|
|
386
|
+
ev = self._read_event()
|
|
387
|
+
if ev == "quit":
|
|
388
|
+
break
|
|
389
|
+
elif ev == "toggle":
|
|
390
|
+
self.no_rich = not self.no_rich
|
|
391
|
+
self.top = 0
|
|
392
|
+
elif ev == "up":
|
|
393
|
+
self.top -= 1
|
|
394
|
+
elif ev == "down":
|
|
395
|
+
self.top += 1
|
|
396
|
+
elif ev == "wheelup":
|
|
397
|
+
self.top -= 3
|
|
398
|
+
elif ev == "wheeldown":
|
|
399
|
+
self.top += 3
|
|
400
|
+
elif ev == "pgup":
|
|
401
|
+
self.top -= max(1, view_h - 1)
|
|
402
|
+
elif ev == "pgdn":
|
|
403
|
+
self.top += max(1, view_h - 1)
|
|
404
|
+
elif ev == "home":
|
|
405
|
+
self.top = 0
|
|
406
|
+
elif ev == "end":
|
|
407
|
+
self.top = max_top
|
|
408
|
+
elif ev == "nextrec":
|
|
409
|
+
if self._advance("next"):
|
|
410
|
+
self.top = 0
|
|
411
|
+
elif ev == "prevrec":
|
|
412
|
+
if self._advance("prev"):
|
|
413
|
+
self.top = 0
|
|
414
|
+
finally:
|
|
415
|
+
sys.stdout.write(
|
|
416
|
+
ESC + "[?1000l" + ESC + "[?1006l" + ESC + "[?25h" + ESC + "[?1049l"
|
|
417
|
+
)
|
|
418
|
+
sys.stdout.flush()
|
|
419
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, old)
|