web-picker 0.2.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.
- web_picker/__init__.py +7 -0
- web_picker/__main__.py +4 -0
- web_picker/app.py +557 -0
- web_picker-0.2.0.dist-info/METADATA +280 -0
- web_picker-0.2.0.dist-info/RECORD +8 -0
- web_picker-0.2.0.dist-info/WHEEL +4 -0
- web_picker-0.2.0.dist-info/entry_points.txt +2 -0
- web_picker-0.2.0.dist-info/licenses/LICENSE +21 -0
web_picker/__init__.py
ADDED
web_picker/__main__.py
ADDED
web_picker/app.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""Web Picker — render multiple HTML previews, let the human pick one."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# PySide6 运行时保护 —— 一些精简安装只带 Essentials,没有 QtWebEngineWidgets
|
|
10
|
+
try:
|
|
11
|
+
from PySide6 import QtWebEngineWidgets # noqa: F401
|
|
12
|
+
except ImportError:
|
|
13
|
+
print(
|
|
14
|
+
"[web-picker] QtWebEngineWidgets is not available in this install.\n"
|
|
15
|
+
" On minimal installs, run: pip install PySide6-Addons\n",
|
|
16
|
+
file=sys.stderr,
|
|
17
|
+
)
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
|
|
20
|
+
from PySide6.QtCore import Qt, QUrl
|
|
21
|
+
from PySide6.QtGui import QGuiApplication
|
|
22
|
+
from PySide6.QtWidgets import (
|
|
23
|
+
QApplication, QMainWindow, QWidget, QFrame,
|
|
24
|
+
QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSplitter, QSlider,
|
|
25
|
+
)
|
|
26
|
+
from PySide6.QtWebEngineCore import QWebEngineSettings
|
|
27
|
+
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# 主题字典 —— 与 svg-picker 完全一致
|
|
31
|
+
THEMES = {
|
|
32
|
+
"cream": {
|
|
33
|
+
"BG_BASE": "#f5f0e1",
|
|
34
|
+
"BG_CARD": "#fbf6e4",
|
|
35
|
+
"BG_HOVER": "#ede2bf",
|
|
36
|
+
"BORDER": "#d8c79f",
|
|
37
|
+
"BORDER_HOVER": "#b9a578",
|
|
38
|
+
"TEXT_PRIMARY": "#2c2418",
|
|
39
|
+
"TEXT_MUTED": "#8a7a5e",
|
|
40
|
+
"ACCENT": "#6366f1",
|
|
41
|
+
"ACCENT_HOVER": "#818cf8",
|
|
42
|
+
"ACCENT_SEL_BG": "rgba(99,102,241,0.15)",
|
|
43
|
+
},
|
|
44
|
+
"sky": {
|
|
45
|
+
"BG_BASE": "#e0f2fe",
|
|
46
|
+
"BG_CARD": "#f0f9ff",
|
|
47
|
+
"BG_HOVER": "#bae6fd",
|
|
48
|
+
"BORDER": "#7dd3fc",
|
|
49
|
+
"BORDER_HOVER": "#38bdf8",
|
|
50
|
+
"TEXT_PRIMARY": "#0c4a6e",
|
|
51
|
+
"TEXT_MUTED": "#64748b",
|
|
52
|
+
"ACCENT": "#0284c7",
|
|
53
|
+
"ACCENT_HOVER": "#0ea5e9",
|
|
54
|
+
"ACCENT_SEL_BG": "rgba(2,132,199,0.15)",
|
|
55
|
+
},
|
|
56
|
+
"dark": {
|
|
57
|
+
"BG_BASE": "#0f1117",
|
|
58
|
+
"BG_CARD": "#0f1117",
|
|
59
|
+
"BG_HOVER": "#1e2130",
|
|
60
|
+
"BORDER": "#2e3347",
|
|
61
|
+
"BORDER_HOVER": "#3d4260",
|
|
62
|
+
"TEXT_PRIMARY": "#e2e4ea",
|
|
63
|
+
"TEXT_MUTED": "#7a7f99",
|
|
64
|
+
"ACCENT": "#6366f1",
|
|
65
|
+
"ACCENT_HOVER": "#818cf8",
|
|
66
|
+
"ACCENT_SEL_BG": "rgba(99,102,241,0.2)",
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
BG_BASE = THEMES["cream"]["BG_BASE"]
|
|
71
|
+
BG_CARD = THEMES["cream"]["BG_CARD"]
|
|
72
|
+
BG_HOVER = THEMES["cream"]["BG_HOVER"]
|
|
73
|
+
BORDER = THEMES["cream"]["BORDER"]
|
|
74
|
+
BORDER_HOVER = THEMES["cream"]["BORDER_HOVER"]
|
|
75
|
+
TEXT_PRIMARY = THEMES["cream"]["TEXT_PRIMARY"]
|
|
76
|
+
TEXT_MUTED = THEMES["cream"]["TEXT_MUTED"]
|
|
77
|
+
ACCENT = THEMES["cream"]["ACCENT"]
|
|
78
|
+
ACCENT_HOVER = THEMES["cream"]["ACCENT_HOVER"]
|
|
79
|
+
ACCENT_SEL_BG = THEMES["cream"]["ACCENT_SEL_BG"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_theme(name):
|
|
83
|
+
"""根据主题名更新模块级颜色常量。"""
|
|
84
|
+
if name not in THEMES:
|
|
85
|
+
raise ValueError(f"Unknown theme '{name}'. Choose from {list(THEMES)}")
|
|
86
|
+
for key, value in THEMES[name].items():
|
|
87
|
+
globals()[key] = value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
MAX_OPTIONS = 9
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def pick_layout(n):
|
|
94
|
+
"""根据选项数选择网格布局。N 必须在 1-9 之间。"""
|
|
95
|
+
if n == 1: return (1, 1)
|
|
96
|
+
if n == 2: return (1, 2)
|
|
97
|
+
if n == 3: return (1, 3)
|
|
98
|
+
if n == 4: return (2, 2)
|
|
99
|
+
if n in (5, 6): return (2, 3)
|
|
100
|
+
return (3, 3) # 7-9
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class WebPageCard(QFrame):
|
|
104
|
+
"""一张可点击的卡片,内含一个 QWebEngineView + 文件名标签。
|
|
105
|
+
|
|
106
|
+
设计要点:
|
|
107
|
+
- QWebEngineView 设了 WA_TransparentForMouseEvents —— 它只负责"看",不接收鼠标。
|
|
108
|
+
所有鼠标事件(包括在内嵌 iframe 上方的)都传给本卡片,QFrame.mousePressEvent 统一处理。
|
|
109
|
+
- 三种状态:idle(默认)、hover(QSS)、selected(强调色边框 + 淡色背景)。
|
|
110
|
+
- 点击 → 通知主窗口"我被选中了",由主窗口统一协调(只能选中一张)。
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, html_path, on_click, slider_handle=0, parent=None):
|
|
114
|
+
super().__init__(parent)
|
|
115
|
+
self.html_path = html_path
|
|
116
|
+
self._on_click = on_click
|
|
117
|
+
self._selected = False
|
|
118
|
+
self.setFrameShape(QFrame.Shape.StyledPanel)
|
|
119
|
+
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
120
|
+
self.setFocusPolicy(Qt.FocusPolicy.NoFocus) # 主窗口统一管键盘
|
|
121
|
+
self.setMinimumSize(220, 180) # 拖手柄不能拖到太小
|
|
122
|
+
|
|
123
|
+
layout = QVBoxLayout(self)
|
|
124
|
+
layout.setContentsMargins(6, 6, 6, 6)
|
|
125
|
+
layout.setSpacing(4)
|
|
126
|
+
|
|
127
|
+
# 标题栏 —— 单行:左 slider + 百分比,右 filename
|
|
128
|
+
title_bar = QHBoxLayout()
|
|
129
|
+
title_bar.setContentsMargins(2, 0, 2, 0)
|
|
130
|
+
title_bar.setSpacing(6)
|
|
131
|
+
|
|
132
|
+
self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
|
|
133
|
+
self.zoom_slider.setRange(25, 200) # 25% – 200%
|
|
134
|
+
self.zoom_slider.setValue(100) # 默认 100%
|
|
135
|
+
self.zoom_slider.setPageStep(25)
|
|
136
|
+
self.zoom_slider.setSingleStep(5)
|
|
137
|
+
self.zoom_slider.setFixedWidth(110)
|
|
138
|
+
self.zoom_slider.setToolTip("缩放:拖动调整预览大小")
|
|
139
|
+
self.zoom_slider.valueChanged.connect(self._on_zoom_changed)
|
|
140
|
+
# 用户传了 --slider-handle → 显式加宽拖把,触控板/触屏好抓
|
|
141
|
+
# 颜色用 TEXT_PRIMARY(各主题自带的"前景色",跟背景协调),
|
|
142
|
+
# 不用 ACCENT,避免米黄主体里冒出蓝色拖把
|
|
143
|
+
if slider_handle > 0:
|
|
144
|
+
self.zoom_slider.setStyleSheet(f"""
|
|
145
|
+
QSlider::groove:horizontal {{
|
|
146
|
+
height: 4px;
|
|
147
|
+
background: {BORDER};
|
|
148
|
+
border-radius: 2px;
|
|
149
|
+
}}
|
|
150
|
+
QSlider::handle:horizontal {{
|
|
151
|
+
background: {TEXT_PRIMARY};
|
|
152
|
+
border: none;
|
|
153
|
+
width: {slider_handle}px;
|
|
154
|
+
height: {slider_handle + 4}px;
|
|
155
|
+
margin: -{(slider_handle + 4 - 4) // 2}px 0;
|
|
156
|
+
border-radius: 3px;
|
|
157
|
+
}}
|
|
158
|
+
QSlider::handle:horizontal:hover {{
|
|
159
|
+
background: {TEXT_PRIMARY};
|
|
160
|
+
border: 2px solid {ACCENT};
|
|
161
|
+
}}
|
|
162
|
+
""")
|
|
163
|
+
title_bar.addWidget(self.zoom_slider)
|
|
164
|
+
|
|
165
|
+
self.zoom_label = QLabel("100%")
|
|
166
|
+
self.zoom_label.setFixedWidth(40)
|
|
167
|
+
self.zoom_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
168
|
+
self.zoom_label.setStyleSheet(
|
|
169
|
+
f"color: {TEXT_MUTED}; font-size: 11px; font-weight: 500; "
|
|
170
|
+
f"background: transparent;"
|
|
171
|
+
)
|
|
172
|
+
title_bar.addWidget(self.zoom_label)
|
|
173
|
+
|
|
174
|
+
title_bar.addStretch()
|
|
175
|
+
|
|
176
|
+
title = QLabel(html_path.name)
|
|
177
|
+
title.setStyleSheet(
|
|
178
|
+
f"color: {TEXT_MUTED}; font-size: 12px; font-weight: 600; "
|
|
179
|
+
f"padding: 2px 4px; background: transparent;"
|
|
180
|
+
)
|
|
181
|
+
title.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
|
182
|
+
title_bar.addWidget(title)
|
|
183
|
+
|
|
184
|
+
layout.addLayout(title_bar)
|
|
185
|
+
|
|
186
|
+
# Webview —— 只渲染,不接收事件
|
|
187
|
+
self.webview = QWebEngineView()
|
|
188
|
+
self.webview.setAttribute(
|
|
189
|
+
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
|
|
190
|
+
)
|
|
191
|
+
self.webview.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
192
|
+
# 隐藏 Chromium 渲染的滚动条
|
|
193
|
+
self.webview.page().settings().setAttribute(
|
|
194
|
+
QWebEngineSettings.WebAttribute.ShowScrollBars, False
|
|
195
|
+
)
|
|
196
|
+
self.webview.setUrl(QUrl.fromLocalFile(str(html_path)))
|
|
197
|
+
layout.addWidget(self.webview, 1) # stretch=1,占满剩余空间
|
|
198
|
+
|
|
199
|
+
self._apply_style()
|
|
200
|
+
|
|
201
|
+
def _apply_style(self):
|
|
202
|
+
if self._selected:
|
|
203
|
+
self.setStyleSheet(f"""
|
|
204
|
+
QFrame {{
|
|
205
|
+
background: {ACCENT_SEL_BG};
|
|
206
|
+
border: 2px solid {ACCENT};
|
|
207
|
+
border-radius: 8px;
|
|
208
|
+
}}
|
|
209
|
+
""")
|
|
210
|
+
else:
|
|
211
|
+
self.setStyleSheet(f"""
|
|
212
|
+
QFrame {{
|
|
213
|
+
background: {BG_CARD};
|
|
214
|
+
border: 2px solid {BORDER};
|
|
215
|
+
border-radius: 8px;
|
|
216
|
+
}}
|
|
217
|
+
QFrame:hover {{
|
|
218
|
+
background: {BG_HOVER};
|
|
219
|
+
border: 2px solid {BORDER_HOVER};
|
|
220
|
+
}}
|
|
221
|
+
""")
|
|
222
|
+
|
|
223
|
+
def set_selected(self, selected: bool):
|
|
224
|
+
"""切换选中态并刷新样式。"""
|
|
225
|
+
if self._selected == selected:
|
|
226
|
+
return
|
|
227
|
+
self._selected = selected
|
|
228
|
+
self._apply_style()
|
|
229
|
+
|
|
230
|
+
def _on_zoom_changed(self, value: int):
|
|
231
|
+
"""Slider 拖动 → 调整 webview 缩放 + 同步百分比标签。
|
|
232
|
+
放大超过 100% 时临时开启滚动条,好让用户能看到超出视口的部分。"""
|
|
233
|
+
self.webview.setZoomFactor(value / 100.0)
|
|
234
|
+
self.zoom_label.setText(f"{value}%")
|
|
235
|
+
show_scroll = value > 100
|
|
236
|
+
self.webview.page().settings().setAttribute(
|
|
237
|
+
QWebEngineSettings.WebAttribute.ShowScrollBars, show_scroll
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def mousePressEvent(self, event):
|
|
241
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
242
|
+
self._on_click(self.html_path)
|
|
243
|
+
super().mousePressEvent(event)
|
|
244
|
+
|
|
245
|
+
def mouseDoubleClickEvent(self, event):
|
|
246
|
+
"""双击:用系统默认浏览器打开该 HTML(便于看真实尺寸/动画)。"""
|
|
247
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
248
|
+
webbrowser.open(self.html_path.as_uri())
|
|
249
|
+
super().mouseDoubleClickEvent(event)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class MainWindow(QMainWindow):
|
|
253
|
+
def __init__(self, html_paths, width=None, height=None, slider_handle=0):
|
|
254
|
+
super().__init__()
|
|
255
|
+
self.html_paths = html_paths
|
|
256
|
+
self._picked = None
|
|
257
|
+
self._selected_index = None # 0-based,当前高亮选中的卡片
|
|
258
|
+
self._cards = []
|
|
259
|
+
self._slider_handle = slider_handle # zoom slider 拖把宽度;0 = 用 Qt 默认
|
|
260
|
+
|
|
261
|
+
n = len(html_paths)
|
|
262
|
+
rows, cols = pick_layout(n)
|
|
263
|
+
self.setWindowTitle(f"web-picker — {n} option{'s' if n != 1 else ''}")
|
|
264
|
+
self.setMinimumSize(800, 600)
|
|
265
|
+
|
|
266
|
+
# 窗口尺寸:用户显式传入 → 用用户的;否则按屏幕 85% 自适应
|
|
267
|
+
screen = QGuiApplication.primaryScreen()
|
|
268
|
+
if screen:
|
|
269
|
+
rect = screen.availableGeometry()
|
|
270
|
+
auto_w = min(int(rect.width() * 0.85), max(1200, cols * 600))
|
|
271
|
+
auto_h = min(int(rect.height() * 0.85), max(800, rows * 450))
|
|
272
|
+
target_w = width if width is not None else auto_w
|
|
273
|
+
target_h = height if height is not None else auto_h
|
|
274
|
+
self.resize(target_w, target_h)
|
|
275
|
+
self.move(rect.center() - self.rect().center())
|
|
276
|
+
else:
|
|
277
|
+
self.resize(
|
|
278
|
+
width if width is not None else 1200,
|
|
279
|
+
height if height is not None else 800,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
self._setup_ui(rows, cols)
|
|
283
|
+
|
|
284
|
+
def _setup_ui(self, rows, cols):
|
|
285
|
+
cw = QWidget()
|
|
286
|
+
self.setCentralWidget(cw)
|
|
287
|
+
|
|
288
|
+
outer = QVBoxLayout(cw)
|
|
289
|
+
outer.setContentsMargins(0, 0, 0, 0)
|
|
290
|
+
outer.setSpacing(0)
|
|
291
|
+
|
|
292
|
+
# 顶部 header —— 左侧说明 + 右侧 Confirm 按钮
|
|
293
|
+
header = QFrame()
|
|
294
|
+
header.setStyleSheet(
|
|
295
|
+
f"QFrame {{ background: {BG_BASE}; border-bottom: 1px solid {BORDER}; }}"
|
|
296
|
+
)
|
|
297
|
+
header.setFixedHeight(58)
|
|
298
|
+
hlayout = QHBoxLayout(header)
|
|
299
|
+
hlayout.setContentsMargins(16, 0, 16, 0)
|
|
300
|
+
hlayout.setSpacing(12)
|
|
301
|
+
|
|
302
|
+
hint = QLabel(
|
|
303
|
+
f"Click to select (or press 1-{len(self.html_paths)}). "
|
|
304
|
+
f"Drag handles to resize. "
|
|
305
|
+
f"Double-click to open in your default browser. "
|
|
306
|
+
f"Confirm (or Enter) commits; Esc cancels."
|
|
307
|
+
)
|
|
308
|
+
hint.setStyleSheet(
|
|
309
|
+
f"color: {TEXT_PRIMARY}; font-size: 14px; background: transparent;"
|
|
310
|
+
)
|
|
311
|
+
hlayout.addWidget(hint)
|
|
312
|
+
hlayout.addStretch()
|
|
313
|
+
|
|
314
|
+
self.confirm_btn = QPushButton("Confirm")
|
|
315
|
+
self.confirm_btn.setFixedWidth(120)
|
|
316
|
+
self.confirm_btn.setEnabled(False)
|
|
317
|
+
self.confirm_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
318
|
+
self.confirm_btn.clicked.connect(self._on_confirm)
|
|
319
|
+
self._apply_confirm_style()
|
|
320
|
+
hlayout.addWidget(self.confirm_btn)
|
|
321
|
+
outer.addWidget(header)
|
|
322
|
+
|
|
323
|
+
# 卡片区 —— 用嵌套 QSplitter,卡片之间有可拖动手柄
|
|
324
|
+
# 单行(rows=1): 一个水平 splitter
|
|
325
|
+
# 多行: 一个垂直 splitter,每行内部是水平 splitter
|
|
326
|
+
container = QWidget()
|
|
327
|
+
container.setStyleSheet(f"background: {BG_BASE};")
|
|
328
|
+
clayout = QVBoxLayout(container)
|
|
329
|
+
clayout.setContentsMargins(12, 12, 12, 12)
|
|
330
|
+
clayout.setSpacing(0)
|
|
331
|
+
outer.addWidget(container, 1)
|
|
332
|
+
|
|
333
|
+
if rows == 1:
|
|
334
|
+
root_splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
335
|
+
else:
|
|
336
|
+
root_splitter = QSplitter(Qt.Orientation.Vertical)
|
|
337
|
+
|
|
338
|
+
self._style_splitter(root_splitter)
|
|
339
|
+
|
|
340
|
+
for r in range(rows):
|
|
341
|
+
if rows == 1:
|
|
342
|
+
row_splitter = root_splitter
|
|
343
|
+
else:
|
|
344
|
+
row_splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
345
|
+
self._style_splitter(row_splitter)
|
|
346
|
+
root_splitter.addWidget(row_splitter)
|
|
347
|
+
|
|
348
|
+
for c in range(cols):
|
|
349
|
+
idx = r * cols + c
|
|
350
|
+
if idx >= len(self.html_paths):
|
|
351
|
+
break
|
|
352
|
+
card = WebPageCard(
|
|
353
|
+
self.html_paths[idx],
|
|
354
|
+
lambda p, i=idx: self._on_card_selected(i),
|
|
355
|
+
slider_handle=self._slider_handle,
|
|
356
|
+
)
|
|
357
|
+
self._cards.append(card)
|
|
358
|
+
row_splitter.addWidget(card)
|
|
359
|
+
|
|
360
|
+
clayout.addWidget(root_splitter)
|
|
361
|
+
|
|
362
|
+
def _style_splitter(self, splitter: QSplitter):
|
|
363
|
+
"""卡片间细分隔线,2px,hover 时变强调色提示可拖。
|
|
364
|
+
|
|
365
|
+
cursor 由 Qt 默认提供(横向/纵向 resize 光标)。
|
|
366
|
+
"""
|
|
367
|
+
splitter.setHandleWidth(2)
|
|
368
|
+
splitter.setChildrenCollapsible(False)
|
|
369
|
+
splitter.setStyleSheet(f"""
|
|
370
|
+
QSplitter::handle {{
|
|
371
|
+
background-color: {BORDER};
|
|
372
|
+
}}
|
|
373
|
+
QSplitter::handle:hover {{
|
|
374
|
+
background-color: {ACCENT};
|
|
375
|
+
}}
|
|
376
|
+
QSplitter::handle:horizontal {{
|
|
377
|
+
width: 2px;
|
|
378
|
+
}}
|
|
379
|
+
QSplitter::handle:vertical {{
|
|
380
|
+
height: 2px;
|
|
381
|
+
}}
|
|
382
|
+
""")
|
|
383
|
+
|
|
384
|
+
def _apply_confirm_style(self):
|
|
385
|
+
"""根据 enabled 状态切换 Confirm 按钮样式。"""
|
|
386
|
+
if self.confirm_btn.isEnabled():
|
|
387
|
+
self.confirm_btn.setStyleSheet(f"""
|
|
388
|
+
QPushButton {{
|
|
389
|
+
background: {ACCENT};
|
|
390
|
+
color: white;
|
|
391
|
+
border: none;
|
|
392
|
+
border-radius: 6px;
|
|
393
|
+
padding: 8px 20px;
|
|
394
|
+
font-size: 14px;
|
|
395
|
+
font-weight: 600;
|
|
396
|
+
}}
|
|
397
|
+
QPushButton:hover {{ background: {ACCENT_HOVER}; }}
|
|
398
|
+
""")
|
|
399
|
+
else:
|
|
400
|
+
self.confirm_btn.setStyleSheet(f"""
|
|
401
|
+
QPushButton {{
|
|
402
|
+
background: {BORDER};
|
|
403
|
+
color: {TEXT_MUTED};
|
|
404
|
+
border: none;
|
|
405
|
+
border-radius: 6px;
|
|
406
|
+
padding: 8px 20px;
|
|
407
|
+
font-size: 14px;
|
|
408
|
+
font-weight: 600;
|
|
409
|
+
}}
|
|
410
|
+
""")
|
|
411
|
+
|
|
412
|
+
def _on_card_selected(self, idx: int):
|
|
413
|
+
"""点击/按数字键 → 高亮该卡片。"""
|
|
414
|
+
# 取消之前的选中
|
|
415
|
+
if self._selected_index is not None:
|
|
416
|
+
self._cards[self._selected_index].set_selected(False)
|
|
417
|
+
# 选中新的
|
|
418
|
+
self._selected_index = idx
|
|
419
|
+
self._cards[idx].set_selected(True)
|
|
420
|
+
# 启用 Confirm
|
|
421
|
+
if not self.confirm_btn.isEnabled():
|
|
422
|
+
self.confirm_btn.setEnabled(True)
|
|
423
|
+
self._apply_confirm_style()
|
|
424
|
+
|
|
425
|
+
def _on_confirm(self):
|
|
426
|
+
if self._selected_index is None:
|
|
427
|
+
return
|
|
428
|
+
self._picked = self.html_paths[self._selected_index]
|
|
429
|
+
print(str(self._picked))
|
|
430
|
+
QApplication.instance().quit()
|
|
431
|
+
|
|
432
|
+
def keyPressEvent(self, event):
|
|
433
|
+
key = event.key()
|
|
434
|
+
if key == Qt.Key.Key_Escape:
|
|
435
|
+
self.close()
|
|
436
|
+
return
|
|
437
|
+
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
|
438
|
+
if self._selected_index is not None:
|
|
439
|
+
self._on_confirm()
|
|
440
|
+
event.accept()
|
|
441
|
+
return
|
|
442
|
+
if Qt.Key.Key_1 <= key <= Qt.Key.Key_9:
|
|
443
|
+
idx = key - Qt.Key.Key_1
|
|
444
|
+
if 0 <= idx < len(self.html_paths):
|
|
445
|
+
self._on_card_selected(idx)
|
|
446
|
+
event.accept()
|
|
447
|
+
return
|
|
448
|
+
super().keyPressEvent(event)
|
|
449
|
+
|
|
450
|
+
def closeEvent(self, event):
|
|
451
|
+
"""用户主动关闭(按 X / Esc / Alt+F4)—— 写一行机器可读的取消标记。"""
|
|
452
|
+
if self._picked is None:
|
|
453
|
+
print(
|
|
454
|
+
"[web-picker] cancelled: window closed without selection",
|
|
455
|
+
file=sys.stderr,
|
|
456
|
+
)
|
|
457
|
+
event.accept()
|
|
458
|
+
QApplication.instance().quit()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def main():
|
|
462
|
+
# 项目级 .env —— 让用户钉一个默认主题,不必每次 --theme
|
|
463
|
+
# 自动建空文件,跟 svg-picker 行为一致
|
|
464
|
+
env_file = Path(".env")
|
|
465
|
+
if not env_file.is_file():
|
|
466
|
+
env_file.write_text(
|
|
467
|
+
"# web-picker config —— 取消注释并改值即可覆盖默认\n"
|
|
468
|
+
"# WEB_PICKER_THEME = sky\n",
|
|
469
|
+
encoding="utf-8",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
env_from_file = {}
|
|
473
|
+
for line in env_file.read_text(encoding="utf-8").splitlines():
|
|
474
|
+
line = line.strip()
|
|
475
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
476
|
+
continue
|
|
477
|
+
k, _, v = line.partition("=")
|
|
478
|
+
env_from_file[k.strip()] = v.strip().strip('"').strip("'")
|
|
479
|
+
|
|
480
|
+
parser = argparse.ArgumentParser(
|
|
481
|
+
prog="web-picker",
|
|
482
|
+
description="Render 1-9 HTML previews in a native grid; human clicks "
|
|
483
|
+
"(or presses 1-9) to highlight, then clicks Confirm "
|
|
484
|
+
"(or presses Enter) to commit. The picked file path is "
|
|
485
|
+
"printed to stdout.",
|
|
486
|
+
)
|
|
487
|
+
parser.add_argument(
|
|
488
|
+
"htmls", nargs="+",
|
|
489
|
+
help=f"1-{MAX_OPTIONS} HTML files to compare",
|
|
490
|
+
)
|
|
491
|
+
parser.add_argument(
|
|
492
|
+
"--theme", "-t",
|
|
493
|
+
choices=list(THEMES.keys()),
|
|
494
|
+
default=None,
|
|
495
|
+
help="背景主题。可选: " + ", ".join(THEMES)
|
|
496
|
+
+ "。优先级: CLI > $WEB_PICKER_THEME > .env > 默认 cream",
|
|
497
|
+
)
|
|
498
|
+
parser.add_argument(
|
|
499
|
+
"--width", type=int, default=None,
|
|
500
|
+
help="窗口宽度(像素)。不传则按屏幕自适应 (默认: 自动)",
|
|
501
|
+
)
|
|
502
|
+
parser.add_argument(
|
|
503
|
+
"--height", type=int, default=None,
|
|
504
|
+
help="窗口高度(像素)。不传则按屏幕自适应 (默认: 自动)",
|
|
505
|
+
)
|
|
506
|
+
parser.add_argument(
|
|
507
|
+
"--maximize", action="store_true",
|
|
508
|
+
help="打开时最大化窗口,占满屏幕可用区域",
|
|
509
|
+
)
|
|
510
|
+
parser.add_argument(
|
|
511
|
+
"--slider-handle", type=int, default=0,
|
|
512
|
+
help="缩放 slider 拖把宽度(像素)。默认 0 = 用 Qt 默认 (推荐 14-24,触控板/触屏用更大)",
|
|
513
|
+
)
|
|
514
|
+
args = parser.parse_args()
|
|
515
|
+
|
|
516
|
+
if args.width is not None and args.width < 100:
|
|
517
|
+
parser.error(f"--width 必须 >= 100,got {args.width}")
|
|
518
|
+
if args.height is not None and args.height < 100:
|
|
519
|
+
parser.error(f"--height 必须 >= 100,got {args.height}")
|
|
520
|
+
if args.maximize and (args.width is not None or args.height is not None):
|
|
521
|
+
parser.error("--maximize 不能与 --width/--height 同时使用")
|
|
522
|
+
if args.slider_handle < 0 or args.slider_handle > 40:
|
|
523
|
+
parser.error(f"--slider-handle 必须在 0-40 之间,got {args.slider_handle}")
|
|
524
|
+
|
|
525
|
+
if not 1 <= len(args.htmls) <= MAX_OPTIONS:
|
|
526
|
+
parser.error(
|
|
527
|
+
f"web-picker supports 1-{MAX_OPTIONS} variants, "
|
|
528
|
+
f"got {len(args.htmls)}. Refine the candidates and rerun."
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
paths = []
|
|
532
|
+
for arg in args.htmls:
|
|
533
|
+
p = Path(arg).resolve()
|
|
534
|
+
if not p.is_file():
|
|
535
|
+
print(f"[web-picker] error: not a file: {arg}", file=sys.stderr)
|
|
536
|
+
sys.exit(1)
|
|
537
|
+
paths.append(p)
|
|
538
|
+
|
|
539
|
+
# 主题优先级:CLI > 环境变量 > .env > 内置默认 cream
|
|
540
|
+
theme = (
|
|
541
|
+
args.theme
|
|
542
|
+
or os.environ.get("WEB_PICKER_THEME")
|
|
543
|
+
or env_from_file.get("WEB_PICKER_THEME")
|
|
544
|
+
or "cream"
|
|
545
|
+
)
|
|
546
|
+
apply_theme(theme)
|
|
547
|
+
|
|
548
|
+
app = QApplication(sys.argv)
|
|
549
|
+
app.setStyle("fusion")
|
|
550
|
+
|
|
551
|
+
win = MainWindow(paths, width=args.width, height=args.height, slider_handle=args.slider_handle)
|
|
552
|
+
if args.maximize:
|
|
553
|
+
win.showMaximized()
|
|
554
|
+
else:
|
|
555
|
+
win.show()
|
|
556
|
+
|
|
557
|
+
sys.exit(app.exec())
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: web-picker
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A native GUI tool for AI agents to ask humans to visually pick one of N HTML variants
|
|
5
|
+
Project-URL: Homepage, https://github.com/human-picker/web-picker
|
|
6
|
+
Project-URL: Source, https://github.com/human-picker/web-picker
|
|
7
|
+
Project-URL: Issues, https://github.com/human-picker/web-picker/issues
|
|
8
|
+
Author-email: RinKokawa <rin@rinco.cc>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agent,compare,gui,html,human-in-the-loop,picker,preview,pyside6,qt,web
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
14
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Software Development
|
|
27
|
+
Classifier: Topic :: Utilities
|
|
28
|
+
Requires-Python: >=3.9
|
|
29
|
+
Requires-Dist: pyside6>=6.5
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine>=4.0; extra == 'dev'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# web-picker
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/web-picker/)
|
|
40
|
+
[](https://pypi.org/project/web-picker/)
|
|
41
|
+
[](https://github.com/human-picker/web-picker/blob/main/LICENSE)
|
|
42
|
+
[](https://pypistats.org/packages/web-picker)
|
|
43
|
+
|
|
44
|
+
**A Human-in-the-Loop Visual Comparator for AI Agents**
|
|
45
|
+
|
|
46
|
+
> Companion to [svg-picker](https://pypi.org/project/svg-picker/), inspired by [HULA: Human-In-the-Loop Software Development Agents](https://arxiv.org/abs/2411.12924) (ICSE SEIP 2025)
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## What Is This?
|
|
51
|
+
|
|
52
|
+
When AI agents write web code, they often propose multiple visual approaches in text — *"should the hero be a left-aligned image with caption, or a centered headline with gradient, or a video background?"* Describing these in markdown is hard for the human to evaluate.
|
|
53
|
+
|
|
54
|
+
**web-picker turns those text descriptions into actual rendered previews.** The AI writes 2-9 candidate HTML files, calls `web-picker a.html b.html c.html`, and a native window opens showing every candidate side-by-side. The human clicks the one they prefer (or presses `1`/`2`/`3`). The picked file path is printed to stdout, which the AI reads to continue with the chosen design.
|
|
55
|
+
|
|
56
|
+
**The human provides visual judgment. The AI handles everything else.**
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Background: Why "Human-in-the-Loop"?
|
|
61
|
+
|
|
62
|
+
The paper [HULA: Human-In-the-Loop Software Development Agents](https://arxiv.org/abs/2411.12924) (Takerngsaksiri et al., ICSE SEIP 2025) demonstrates a critical insight:
|
|
63
|
+
|
|
64
|
+
> Existing LLM-based coding agents rarely incorporate human feedback at intermediate stages. When humans can intervene during plan generation and code writing — not just review final output — development time and effort decrease significantly.
|
|
65
|
+
|
|
66
|
+
web-picker applies this principle to a specific, narrow task: **HTML variant selection**. It is the visual-design counterpart to [svg-picker](https://pypi.org/project/svg-picker/), which solves icon selection. Together they form a minimal toolkit for AI agents to consult humans on small, reversible design decisions without dragging them into a full review loop.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## How It Works
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
User/CI: web-picker opt1.html opt2.html opt3.html
|
|
74
|
+
│
|
|
75
|
+
▼
|
|
76
|
+
┌────────────────────────────────┐
|
|
77
|
+
│ Native window (PySide6) │
|
|
78
|
+
│ ┌─────────┐ ┌─────────┐ ┌────┐│
|
|
79
|
+
│ │ opt1 │ │ opt2 │ │op3 ││
|
|
80
|
+
│ │ HTML │ │ HTML │ │HTML││
|
|
81
|
+
│ │ preview │ │ preview │ │prv ││
|
|
82
|
+
│ └─────────┘ └─────────┘ └────┘│
|
|
83
|
+
│ ← Human clicks / presses 1-3 │
|
|
84
|
+
│ (highlights the card) │
|
|
85
|
+
│ ← Then clicks Confirm or ↩ │
|
|
86
|
+
└──────────────────┬─────────────┘
|
|
87
|
+
│
|
|
88
|
+
▼
|
|
89
|
+
Picked path → stdout → AI reads it
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Two-step pick: clicking a card (or pressing `1`-`9`) **highlights** it; only clicking **Confirm** (or pressing `Enter`) commits the choice. This gives hesitant users a moment to look, then change their mind, before committing. The page itself is **view-only** — iframe interactivity is intentionally sacrificed so a click on the card surface always means "select this one".
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Features
|
|
97
|
+
|
|
98
|
+
- **Native GUI** — PySide6 window, no browser popup
|
|
99
|
+
- **HTML rendering** — each option rendered via embedded Chromium (`QWebEngineView`)
|
|
100
|
+
- **Adaptive grid** — 1-9 options laid out to maximize per-card area
|
|
101
|
+
- **Draggable resize** — drag the handles between cards to make any preview wider/taller
|
|
102
|
+
- **Open in real browser** — double-click a card to open it in your system default browser for full-size inspection
|
|
103
|
+
- **Two-step pick** — click a card to highlight, then Confirm (or `Enter`) to commit; users can change their mind before committing
|
|
104
|
+
- **Keyboard shortcuts** — `1`-`9` to highlight, `Enter` to confirm, `Esc` to cancel
|
|
105
|
+
- **Cancellation signal** — closing the window writes `[web-picker] cancelled: ...` to stderr
|
|
106
|
+
- **Themable** — `cream` / `sky` / `dark` via `--theme`
|
|
107
|
+
- **One-step install** — `pip install web-picker`, single command
|
|
108
|
+
- **Zero config** — no API keys, no servers, no infrastructure
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Install
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install web-picker
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Or for development:
|
|
119
|
+
```bash
|
|
120
|
+
pip install -e .
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Troubleshooting**: if you see `QtWebEngineWidgets is not available in this install`, run `pip install PySide6-Addons`. Some minimal PySide6 installs ship only the Essentials subset.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Usage
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
web-picker <file1.html> [file2.html ...] # 1-9 files
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Options
|
|
134
|
+
|
|
135
|
+
| Flag | Description |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `-t`, `--theme <name>` | Background theme. Choices: `cream` (default), `sky`, `dark` |
|
|
138
|
+
| `--width <px>` | Override window width in pixels (default: auto-fit screen) |
|
|
139
|
+
| `--height <px>` | Override window height in pixels (default: auto-fit screen) |
|
|
140
|
+
| `--maximize` | Open the window maximized to fill the screen (cannot combine with `--width`/`--height`) |
|
|
141
|
+
| `--slider-handle <px>` | Zoom-slider knob width in pixels (default: `0` = Qt default; recommended `14`-`24` for trackpad/touch) |
|
|
142
|
+
|
|
143
|
+
#### Default Theme via `.env`
|
|
144
|
+
|
|
145
|
+
Don't want to type `--theme dark` every time? Drop a `.env` in the directory you launch `web-picker` from:
|
|
146
|
+
|
|
147
|
+
```env
|
|
148
|
+
# Uncomment to override the default theme
|
|
149
|
+
# WEB_PICKER_THEME = sky
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Precedence: `--theme` CLI flag > `$WEB_PICKER_THEME` shell variable > `.env` file > built-in `cream`.
|
|
153
|
+
|
|
154
|
+
The `.env` file is created automatically on first launch with the options commented out.
|
|
155
|
+
|
|
156
|
+
### Examples
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
web-picker hero-a.html hero-b.html hero-c.html # 3 hero variants
|
|
160
|
+
web-picker landing.html # confirm a single design
|
|
161
|
+
web-picker card.html card-dark.html card-outline.html card-flat.html --theme dark
|
|
162
|
+
web-picker hero-a.html hero-b.html --maximize # fill the screen for easier preview
|
|
163
|
+
web-picker hero-a.html hero-b.html --width 1920 --height 1080 # pin to a specific size
|
|
164
|
+
web-picker hero-a.html hero-b.html hero-c.html --slider-handle 20 # chunky zoom knobs for trackpad use
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### The HTML Input Contract
|
|
168
|
+
|
|
169
|
+
Each file passed to `web-picker` MUST be a **complete, standalone HTML document** — `<!DOCTYPE html>` through `</html>`. The agent is responsible for writing them; web-picker does no rendering magic.
|
|
170
|
+
|
|
171
|
+
Relative paths (CSS, images, fonts) work fine because each file is loaded via `file://`. Inline styles, external CDNs, and even `<script>` blocks are all permitted and rendered as-is. Each option is a real browser tab — animations, hover effects, the works.
|
|
172
|
+
|
|
173
|
+
A minimal example (each file is a full HTML page):
|
|
174
|
+
|
|
175
|
+
```html
|
|
176
|
+
<!-- hero-a.html -->
|
|
177
|
+
<!DOCTYPE html>
|
|
178
|
+
<html><body style="margin:0; font-family:sans-serif">
|
|
179
|
+
<div style="height:100vh; display:grid; place-items:center; background:#1e3a8a; color:white">
|
|
180
|
+
<h1>Welcome to Acme</h1>
|
|
181
|
+
</div>
|
|
182
|
+
</body></html>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Layout Strategy
|
|
186
|
+
|
|
187
|
+
| Options | Grid |
|
|
188
|
+
|---|---|
|
|
189
|
+
| 1 | 1×1 |
|
|
190
|
+
| 2 | 1×2 |
|
|
191
|
+
| 3 | 1×3 |
|
|
192
|
+
| 4 | 2×2 |
|
|
193
|
+
| 5-6 | 2×3 |
|
|
194
|
+
| 7-9 | 3×3 |
|
|
195
|
+
|
|
196
|
+
More than 9 options is rejected — the human can't meaningfully compare that many at once, and 10+ `QWebEngineView` instances will exhaust your RAM.
|
|
197
|
+
|
|
198
|
+
### Steps
|
|
199
|
+
|
|
200
|
+
1. Window opens, every option rendered side-by-side
|
|
201
|
+
2. **Drag** the handles between cards to resize any preview — if a card feels too narrow, pull it wider to inspect the detail
|
|
202
|
+
3. **Double-click** a card to open it in your system default browser (useful when an embedded preview is too small to judge typography or animations)
|
|
203
|
+
4. **Click** a card (or press its number key `1`-`9`) to highlight it — a purple border marks your current selection
|
|
204
|
+
5. **Click another card** to change your selection, or click **Confirm** (top-right) / press `Enter` to commit
|
|
205
|
+
6. Window closes; the picked file's absolute path is on stdout
|
|
206
|
+
7. **Close the window** (X) or press `Esc` to cancel — a `[web-picker] cancelled: ...` line is written to stderr
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## For AI Agents
|
|
211
|
+
|
|
212
|
+
### As a Claude Code Skill
|
|
213
|
+
|
|
214
|
+
Place this file as `~/.claude/skills/web-picker.md`:
|
|
215
|
+
|
|
216
|
+
```markdown
|
|
217
|
+
# web-picker
|
|
218
|
+
|
|
219
|
+
Compare 2-9 HTML variants and let the human visually pick one.
|
|
220
|
+
|
|
221
|
+
Usage: web-picker <file1.html> [file2.html ...]
|
|
222
|
+
|
|
223
|
+
The human clicks a card (or presses 1-9) to highlight, then clicks
|
|
224
|
+
Confirm (or presses Enter) to commit. The picked file's absolute
|
|
225
|
+
path is printed to stdout. If the window is closed without confirming,
|
|
226
|
+
a "[web-picker] cancelled: ..." line is written to stderr — read stderr
|
|
227
|
+
to distinguish cancel from crash.
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Programmatic Usage
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
import subprocess
|
|
234
|
+
|
|
235
|
+
result = subprocess.run(
|
|
236
|
+
["web-picker", "hero-a.html", "hero-b.html", "hero-c.html"],
|
|
237
|
+
capture_output=True, text=True,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if result.returncode != 0:
|
|
241
|
+
raise RuntimeError(f"web-picker crashed: {result.stderr}")
|
|
242
|
+
|
|
243
|
+
if "[web-picker] cancelled" in result.stderr:
|
|
244
|
+
# 用户主动关闭窗口,没选
|
|
245
|
+
print("User cancelled without picking")
|
|
246
|
+
else:
|
|
247
|
+
# 正常完成 —— result.stdout 是被选中的文件绝对路径
|
|
248
|
+
chosen_path = result.stdout.strip()
|
|
249
|
+
print(f"User picked: {chosen_path}")
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Comparison
|
|
255
|
+
|
|
256
|
+
| | web-picker | svg-picker | HULA (Atlassian) |
|
|
257
|
+
|---|---|---|---|
|
|
258
|
+
| Decision type | HTML variant (1 of N) | SVG icon (N of M) | Full software dev |
|
|
259
|
+
| Visual surface | Embedded web pages | Icon thumbnails | Plan + code review |
|
|
260
|
+
| Scope | Single tool, single task | Single tool, single task | Full agent framework |
|
|
261
|
+
| Human role | Visual design judge | Visual icon judge | Plan + code reviewer |
|
|
262
|
+
| Deployment | `pip install` | `pip install` | Jira plugin |
|
|
263
|
+
| Target | AI agents | AI agents | Human engineers |
|
|
264
|
+
|
|
265
|
+
web-picker and svg-picker share the same philosophy: **the human only intervenes on narrow, reversible, visual decisions** — everything else stays with the agent.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Related Work
|
|
270
|
+
|
|
271
|
+
- [HULA: Human-In-the-Loop Software Development Agents](https://arxiv.org/abs/2411.12924) — ICSE SEIP 2025
|
|
272
|
+
- [svg-picker](https://pypi.org/project/svg-picker/) — sibling tool for icon selection
|
|
273
|
+
- [acte](https://github.com/j66n/acte) — Framework for GUI-like Agent Tools
|
|
274
|
+
- [OpenUI](https://github.com/thesysdev/openui) — Open Standard for Generative UI
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
web_picker/__init__.py,sha256=vdMibxOuY_9t8OR8x4Ypw0ydmKq3B8F225BdoScsfuQ,152
|
|
2
|
+
web_picker/__main__.py,sha256=YfjoadGknsNg1NA49mNlre72X9ow2OhDjSPStoo8x2o,94
|
|
3
|
+
web_picker/app.py,sha256=iqdwntUkwgBlb2zRjRn8gPYTVmx04oFQSDk54xLlLj8,20340
|
|
4
|
+
web_picker-0.2.0.dist-info/METADATA,sha256=F14amVzlsmYIMI8KbMx3Vk41dOwQWesLEM-AlPfreK8,12020
|
|
5
|
+
web_picker-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
web_picker-0.2.0.dist-info/entry_points.txt,sha256=x84tsbgdohSsSmS7OL_edN03LRf0duCFB_mt0f9l7lU,51
|
|
7
|
+
web_picker-0.2.0.dist-info/licenses/LICENSE,sha256=keU11UEIUMTVEQHCrIjNASMypSyYEijEq-1MzqD9_0k,1066
|
|
8
|
+
web_picker-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RinKokawa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|