folderui 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
folderui-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xiao-e-yun
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.
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.5
2
+ Name: folderui
3
+ Version: 0.1.0
4
+ Summary: Turn a folder into an immediate-mode GUI for humans and AI agents: *.md files are the view, *.sh files are buttons.
5
+ Project-URL: Homepage, https://github.com/xiao-e-yun/folderui
6
+ Project-URL: Repository, https://github.com/xiao-e-yun/folderui
7
+ Author: xiao-e-yun
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,gui,immediate-mode,llm,multiplayer,turn-based
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # folderui
21
+
22
+ 把**資料夾變成立即式 GUI**,給人類和 AI agent 使用。資料夾裡只有兩種東西:
23
+
24
+ - `*.md` 等檔案:畫面(隨時可以 `cat`)
25
+ - `*.sh`:按鈕(只有現在能按的才會出現)
26
+
27
+ 執行按鈕後,腳本會**一直等到你需要做下一個決定**才回應,並印出:操作結果 + 這段期間發生的事 + 目前狀況 + 可以按的按鈕。
28
+ 所以 agent 的迴圈就是「執行按鈕 → 讀回應 → 執行下一個按鈕」,不需要另外等待或輪詢。
29
+
30
+ ```sh
31
+ pip install folderui # 或 uv add folderui;只用標準函式庫,需要 POSIX shell
32
+ ```
33
+
34
+ ```console
35
+ $ ./bid.sh 40
36
+ ⏳ 已送出,等待中… ← 其他人還在出價,腳本卡住
37
+ ✅ 已出價 40
38
+ 開標:bob 5、carol 15、你 40
39
+ 🔨 你以 40 得標 …
40
+ 🔔 bob 拍賣「月光琉璃盞」…
41
+
42
+ ── 可以做的事 ──
43
+ ./bid.sh <金額 1-60> 密封出價
44
+ ./pass.sh 不出價
45
+ ```
46
+
47
+ ## 寫一個 UI
48
+
49
+ ```python
50
+ from folderui import FolderUI
51
+
52
+ class Guess(FolderUI):
53
+ def draw(self): # 每一幀重新宣告畫面,不可修改狀態
54
+ self.file("rules.md", "猜 1-100")
55
+ self.button("guess", "<數字>")
56
+ self.reply(f"已經猜了 {len(self.tries)} 次")
57
+
58
+ def action(self, name, args): # 按鈕被按下:回傳文字,或 raise ValueError 代表失敗
59
+ n = int(args[0])
60
+ ...
61
+ return "太大"
62
+
63
+ Guess("/tmp/my-ui").run()
64
+ ```
65
+
66
+ | 方法 | 在哪裡呼叫 | 作用 |
67
+ |---|---|---|
68
+ | `file(path, content)` | `draw()` | 宣告一個檔案;沒宣告的會自動刪除 |
69
+ | `button(path, hint, options=None)` | `draw()` | 宣告一個按鈕 → `<path>.sh`,路徑可以分層(`players/2/kick`) |
70
+ | `reply(text)` | `draw()` | 腳本回應時印出的目前狀況 |
71
+ | `notify(text)` | `action()` / `receive()` | 累積一則消息,下一次回應時送出 |
72
+ | `close()` | `action()` / `receive()` | 結束:移除按鈕、回應等待中的腳本 |
73
+ | `push(msg)` | 其他執行緒 | 送訊息給這個 UI,會呼叫 `receive(msg)` |
74
+
75
+ **回應規則只有一條**:操作處理完之後,第一個「有按鈕可按」的畫面出現時(或 `close()`)才回應。
76
+
77
+ - 輸入錯誤 → 狀態沒變、按鈕還在 → 立刻回應(`raise` 的訊息,exit code 1)
78
+ - 查詢類操作 → 按鈕還在 → 立刻回應
79
+ - 送出後要等別人 → 沒有按鈕 → 等到輪到你
80
+
81
+ ## 多人:1 個伺服器 + N 個客戶端
82
+
83
+ ```python
84
+ class MyServer(Server):
85
+ def handle(self, who, cmd): # 純邏輯;raise ValueError 代表規則不允許
86
+ ...
87
+ for p in self.clients:
88
+ self.push(p, Update(...)) # 推播給客戶端
89
+ return "✅ 已出價"
90
+
91
+ class PlayerUI(FolderUI):
92
+ def action(self, name, args):
93
+ return self.server.call(self.me, ("bid", int(args[0]))) # 同步呼叫,例外會傳回來
94
+ def receive(self, update):
95
+ self.view = update.view # 伺服器推播 → 框架自動重畫
96
+
97
+ server = MyServer()
98
+ for i in range(n):
99
+ server.connect(i, PlayerUI(f"{root}/player_{i}", server, i))
100
+ server.start()
101
+ for ui in server.clients.values():
102
+ ui.start()
103
+ ```
104
+
105
+ 框架不管多人同步:伺服器在一個執行緒依序處理請求,推播走無上限佇列(伺服器永遠不等客戶端)。
106
+
107
+ ## 程式型 agent
108
+
109
+ ```python
110
+ from folderui import Session
111
+ s = Session("/tmp/folderui-auction-xxxx/player_0")
112
+ s.ready()
113
+ while buttons := s.buttons(): # {路徑: {hint, options}},空的代表結束
114
+ ok, text = s.press("bid", "40")
115
+ ```
116
+
117
+ ## 細節
118
+
119
+ - 腳本預設無限等待。設定 `FOLDERUI_TIMEOUT=秒數` 會在逾時後以 exit code 2 結束;
120
+ **再執行一次同樣的指令即可繼續等待或拿到回應**(不會重複送出)。回應被讀走之前,該按鈕的腳本會保留。
121
+ - 協定檔案都是隱藏檔:`.request`、`.response`、`.done`、`.buttons.json`。
122
+ 不能執行腳本的 agent 也可以直接寫 `.request`(按鈕路徑與參數以 NUL 分隔)。
123
+
124
+ ## 範例
125
+
126
+ ```sh
127
+ uv run python examples/guess.py # 單人:猜數字
128
+ uv run python examples/auction.py --players 4 --rounds 3 # 多人:英式拍賣(玩家 + 觀眾)
129
+ uv run python examples/auction_bot.py <資料夾> bot0 # 拍賣用的隨機 bot
130
+ ```
131
+
132
+ ## 開發
133
+
134
+ ```sh
135
+ uv sync
136
+ uv run pytest
137
+ ```
138
+
139
+ 發布:在 PyPI 設定 trusted publisher(repo `xiao-e-yun/folderui`、workflow `publish.yml`、environment `pypi`),
140
+ 之後改 `pyproject.toml` 的版本號並推送 `v*` tag 就會自動發布。
@@ -0,0 +1,121 @@
1
+ # folderui
2
+
3
+ 把**資料夾變成立即式 GUI**,給人類和 AI agent 使用。資料夾裡只有兩種東西:
4
+
5
+ - `*.md` 等檔案:畫面(隨時可以 `cat`)
6
+ - `*.sh`:按鈕(只有現在能按的才會出現)
7
+
8
+ 執行按鈕後,腳本會**一直等到你需要做下一個決定**才回應,並印出:操作結果 + 這段期間發生的事 + 目前狀況 + 可以按的按鈕。
9
+ 所以 agent 的迴圈就是「執行按鈕 → 讀回應 → 執行下一個按鈕」,不需要另外等待或輪詢。
10
+
11
+ ```sh
12
+ pip install folderui # 或 uv add folderui;只用標準函式庫,需要 POSIX shell
13
+ ```
14
+
15
+ ```console
16
+ $ ./bid.sh 40
17
+ ⏳ 已送出,等待中… ← 其他人還在出價,腳本卡住
18
+ ✅ 已出價 40
19
+ 開標:bob 5、carol 15、你 40
20
+ 🔨 你以 40 得標 …
21
+ 🔔 bob 拍賣「月光琉璃盞」…
22
+
23
+ ── 可以做的事 ──
24
+ ./bid.sh <金額 1-60> 密封出價
25
+ ./pass.sh 不出價
26
+ ```
27
+
28
+ ## 寫一個 UI
29
+
30
+ ```python
31
+ from folderui import FolderUI
32
+
33
+ class Guess(FolderUI):
34
+ def draw(self): # 每一幀重新宣告畫面,不可修改狀態
35
+ self.file("rules.md", "猜 1-100")
36
+ self.button("guess", "<數字>")
37
+ self.reply(f"已經猜了 {len(self.tries)} 次")
38
+
39
+ def action(self, name, args): # 按鈕被按下:回傳文字,或 raise ValueError 代表失敗
40
+ n = int(args[0])
41
+ ...
42
+ return "太大"
43
+
44
+ Guess("/tmp/my-ui").run()
45
+ ```
46
+
47
+ | 方法 | 在哪裡呼叫 | 作用 |
48
+ |---|---|---|
49
+ | `file(path, content)` | `draw()` | 宣告一個檔案;沒宣告的會自動刪除 |
50
+ | `button(path, hint, options=None)` | `draw()` | 宣告一個按鈕 → `<path>.sh`,路徑可以分層(`players/2/kick`) |
51
+ | `reply(text)` | `draw()` | 腳本回應時印出的目前狀況 |
52
+ | `notify(text)` | `action()` / `receive()` | 累積一則消息,下一次回應時送出 |
53
+ | `close()` | `action()` / `receive()` | 結束:移除按鈕、回應等待中的腳本 |
54
+ | `push(msg)` | 其他執行緒 | 送訊息給這個 UI,會呼叫 `receive(msg)` |
55
+
56
+ **回應規則只有一條**:操作處理完之後,第一個「有按鈕可按」的畫面出現時(或 `close()`)才回應。
57
+
58
+ - 輸入錯誤 → 狀態沒變、按鈕還在 → 立刻回應(`raise` 的訊息,exit code 1)
59
+ - 查詢類操作 → 按鈕還在 → 立刻回應
60
+ - 送出後要等別人 → 沒有按鈕 → 等到輪到你
61
+
62
+ ## 多人:1 個伺服器 + N 個客戶端
63
+
64
+ ```python
65
+ class MyServer(Server):
66
+ def handle(self, who, cmd): # 純邏輯;raise ValueError 代表規則不允許
67
+ ...
68
+ for p in self.clients:
69
+ self.push(p, Update(...)) # 推播給客戶端
70
+ return "✅ 已出價"
71
+
72
+ class PlayerUI(FolderUI):
73
+ def action(self, name, args):
74
+ return self.server.call(self.me, ("bid", int(args[0]))) # 同步呼叫,例外會傳回來
75
+ def receive(self, update):
76
+ self.view = update.view # 伺服器推播 → 框架自動重畫
77
+
78
+ server = MyServer()
79
+ for i in range(n):
80
+ server.connect(i, PlayerUI(f"{root}/player_{i}", server, i))
81
+ server.start()
82
+ for ui in server.clients.values():
83
+ ui.start()
84
+ ```
85
+
86
+ 框架不管多人同步:伺服器在一個執行緒依序處理請求,推播走無上限佇列(伺服器永遠不等客戶端)。
87
+
88
+ ## 程式型 agent
89
+
90
+ ```python
91
+ from folderui import Session
92
+ s = Session("/tmp/folderui-auction-xxxx/player_0")
93
+ s.ready()
94
+ while buttons := s.buttons(): # {路徑: {hint, options}},空的代表結束
95
+ ok, text = s.press("bid", "40")
96
+ ```
97
+
98
+ ## 細節
99
+
100
+ - 腳本預設無限等待。設定 `FOLDERUI_TIMEOUT=秒數` 會在逾時後以 exit code 2 結束;
101
+ **再執行一次同樣的指令即可繼續等待或拿到回應**(不會重複送出)。回應被讀走之前,該按鈕的腳本會保留。
102
+ - 協定檔案都是隱藏檔:`.request`、`.response`、`.done`、`.buttons.json`。
103
+ 不能執行腳本的 agent 也可以直接寫 `.request`(按鈕路徑與參數以 NUL 分隔)。
104
+
105
+ ## 範例
106
+
107
+ ```sh
108
+ uv run python examples/guess.py # 單人:猜數字
109
+ uv run python examples/auction.py --players 4 --rounds 3 # 多人:英式拍賣(玩家 + 觀眾)
110
+ uv run python examples/auction_bot.py <資料夾> bot0 # 拍賣用的隨機 bot
111
+ ```
112
+
113
+ ## 開發
114
+
115
+ ```sh
116
+ uv sync
117
+ uv run pytest
118
+ ```
119
+
120
+ 發布:在 PyPI 設定 trusted publisher(repo `xiao-e-yun/folderui`、workflow `publish.yml`、environment `pypi`),
121
+ 之後改 `pyproject.toml` 的版本號並推送 `v*` tag 就會自動發布。
@@ -0,0 +1,368 @@
1
+ """多人範例:拍賣。1 個伺服器(純遊戲邏輯)+ N 個玩家客戶端 + 1 個觀眾客戶端。
2
+
3
+ uv run python examples/auction.py [--players 4] [--rounds 3] [--seed N] [--dir DIR]
4
+
5
+ 規則:每輪每人從 3 件寶物選 1 件拍賣;賣家寫介紹,買家只看得到名稱和介紹,輪流公開喊價。
6
+ """
7
+ import argparse
8
+ import random
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from folderui import FolderUI, Server
14
+
15
+ NAMES = ["龍紋古劍", "月光琉璃盞", "青銅羅盤", "鳳羽披風", "沉船金幣", "賢者手稿",
16
+ "翡翠面具", "星象儀", "黑曜石戒指", "古神雕像", "鍊金術士之瓶", "王冠碎片",
17
+ "水晶頭骨", "精靈豎琴", "海妖之淚", "隕鐵匕首", "藏寶圖", "不老藥"]
18
+
19
+ TRAITS = [("皇室收藏", 30), ("名家之作", 25), ("古老", 20), ("鑲金", 15), ("完好無缺", 10),
20
+ ("稀有", 10), ("普通", 0), ("來路不明", -10), ("有裂痕", -15), ("被詛咒", -30),
21
+ ("贗品", -40)]
22
+
23
+ RULES = """# 拍賣(共 {rounds} 輪)
24
+
25
+ 1. 每輪開始時每人拿到 3 件新寶物,每件有底價和 2 個詞條(會加減價值),只有你自己看得到。
26
+ 選 1 件當你這輪的拍品,其餘兩件丟棄。
27
+ 2. 每位玩家輪流當賣家,寫一段介紹。**買家只看得到寶物名稱和這段介紹**,可以誇大或隱瞞。
28
+ 3. 公開喊價:從賣家的下一位開始輪流,輪到你時可以加價或退出這件拍品(退出後不能再加入)。
29
+ 第一口至少 1,之後每次至少比目前最高價多 {step}。只剩最高出價者時得標,付錢給賣家。
30
+ 4. 每場結束後公開寶物的詞條和真實價值,所有紀錄都在 history.md。
31
+ 5. 起始每人 100 金。總分 = 金錢 + 擁有寶物的真實價值,最高者獲勝。
32
+ """
33
+
34
+
35
+ STEP = 5 # 加價的最小幅度
36
+
37
+
38
+ @dataclass
39
+ class Treasure:
40
+ name: str
41
+ base: int
42
+ traits: list[tuple[str, int]]
43
+
44
+ @property
45
+ def value(self) -> int:
46
+ return max(0, self.base + sum(v for _, v in self.traits))
47
+
48
+ def card(self) -> str:
49
+ lines = [f"**{self.name}**(真實價值 {self.value})", f"- 底價 {self.base}"]
50
+ return "\n".join(lines + [f"- {t}({v:+d})" for t, v in self.traits])
51
+
52
+
53
+ @dataclass
54
+ class Update:
55
+ view: dict
56
+ events: list[str]
57
+
58
+
59
+ # ---------------- 伺服器:純遊戲邏輯,不知道資料夾的存在 ----------------
60
+
61
+ class AuctionServer(Server):
62
+ step = STEP
63
+
64
+ def __init__(self, n, rounds=1, seed=None, money=100):
65
+ super().__init__()
66
+ self.rng = random.Random(seed)
67
+ self.n, self.rounds, self.round = n, rounds, 0
68
+ self.names: list[str | None] = [None] * n
69
+ self.money = [money] * n
70
+ self.owned: list[list[Treasure]] = [[] for _ in range(n)]
71
+ self.history: list[str] = []
72
+ self.phase = "join" # join → (pick → describe ⇄ bid) × rounds → over
73
+ self.seller = 0
74
+ self.sold = 0 # 這輪已經拍完幾場
75
+ self.pitch = ""
76
+ self.high, self.leader = 0, None # 目前最高價與出價者
77
+ self.active: set[int] = set() # 還沒退出這件拍品的買家
78
+ self.turn = None # 輪到誰喊價
79
+ self.bidlog: list[str] = []
80
+ self.outbox: dict = {}
81
+ self.deal()
82
+
83
+ def deal(self):
84
+ self.round += 1
85
+ self.hands = [[Treasure(name, self.rng.randint(20, 60), self.rng.sample(TRAITS, 2))
86
+ for name in self.rng.sample(NAMES, 3)] for _ in range(self.n)]
87
+ self.lots: list[Treasure | None] = [None] * self.n
88
+ self.seller = (self.round - 1) % self.n # 每輪換人先賣
89
+ self.sold = 0
90
+
91
+ def name(self, p):
92
+ return self.names[p] or f"player_{p}"
93
+
94
+ def say(self, text, to=None):
95
+ for who in (self.clients if to is None else to):
96
+ self.outbox.setdefault(who, []).append(text)
97
+
98
+ def publish(self):
99
+ for who in self.clients:
100
+ self.push(who, Update(self.view(who), self.outbox.pop(who, [])))
101
+
102
+ def handle(self, p, cmd):
103
+ kind, arg = cmd
104
+ result = getattr(self, f"do_{kind}")(p, arg)
105
+ self.publish()
106
+ return result
107
+
108
+ def do_join(self, p, name):
109
+ if self.phase != "join" or self.names[p]:
110
+ raise ValueError("現在不能加入")
111
+ if not 1 <= len(name) <= 20 or name in self.names:
112
+ raise ValueError("名字需為 1-20 字,且不能和別人重複")
113
+ self.names[p] = name
114
+ if all(self.names):
115
+ self.phase = "pick"
116
+ self.say("所有玩家已加入:" + "、".join(self.names) + f"\n\n## 第 1/{self.rounds} 輪")
117
+ return f"✅ 歡迎,{name}"
118
+
119
+ def do_pick(self, p, tname):
120
+ if self.phase != "pick" or self.lots[p]:
121
+ raise ValueError("現在不能選寶物")
122
+ lot = next((t for t in self.hands[p] if t.name == tname), None)
123
+ if lot is None:
124
+ raise ValueError("選項必須是:" + " / ".join(t.name for t in self.hands[p]))
125
+ self.lots[p] = lot
126
+ if all(self.lots):
127
+ self.phase = "describe"
128
+ self.say(f"每位玩家都選好了拍品,由 {self.name(self.seller)} 先賣。")
129
+ return f"✅ 你選了 {tname}"
130
+
131
+ def do_describe(self, p, text):
132
+ if self.phase != "describe" or p != self.seller:
133
+ raise ValueError("現在不是你介紹的時候")
134
+ if not 1 <= len(text) <= 500:
135
+ raise ValueError("介紹需為 1-500 字")
136
+ self.pitch, self.phase = text, "bid"
137
+ self.high, self.leader, self.bidlog = 0, None, []
138
+ self.active = {b for b in range(self.n) if b != p}
139
+ self.turn = (p + 1) % self.n
140
+ self.say(f"🔔 {self.name(p)} 拍賣「{self.lots[p].name}」:\n> {text}\n\n由 {self.name(self.turn)} 先喊價。")
141
+ return "✅ 已送出介紹"
142
+
143
+ def min_bid(self):
144
+ return self.high + self.step if self.leader is not None else 1
145
+
146
+ def do_bid(self, p, amount):
147
+ if self.phase != "bid" or p != self.turn:
148
+ raise ValueError("現在不是你喊價的時候")
149
+ if amount is None:
150
+ self.active.discard(p)
151
+ self.bidlog.append(f"{self.name(p)} 退出")
152
+ else:
153
+ if not self.min_bid() <= amount <= self.money[p]:
154
+ raise ValueError(f"出價需在 {self.min_bid()}-{self.money[p]} 之間"
155
+ if self.min_bid() <= self.money[p] else "你的錢不夠加價,只能退出")
156
+ self.high, self.leader = amount, p
157
+ self.bidlog.append(f"{self.name(p)} {amount}")
158
+ self.say(f"📣 {self.bidlog[-1]}" + (f"(目前最高:{self.name(self.leader)} {self.high})"
159
+ if self.leader is not None else ""))
160
+ rivals = self.active - {self.leader}
161
+ if not rivals:
162
+ self.settle()
163
+ else:
164
+ self.turn = next(c for k in range(1, self.n + 1)
165
+ if (c := (p + k) % self.n) in rivals)
166
+ return f"✅ 已出價 {amount}" if amount else "✅ 已退出這件拍品"
167
+
168
+ def settle(self):
169
+ s, lot = self.seller, self.lots[self.seller]
170
+ self.turn = None
171
+ if self.leader is not None:
172
+ w, price = self.leader, self.high
173
+ self.money[w] -= price
174
+ self.money[s] += price
175
+ self.owned[w].append(lot)
176
+ result = f"🔨 {self.name(w)} 以 {price} 得標"
177
+ else:
178
+ self.owned[s].append(lot)
179
+ result = f"流標,{self.name(s)} 留下寶物"
180
+ self.say(f"{result}。揭曉:\n{lot.card()}")
181
+ self.history.append(f"## 第 {self.round} 輪:{self.name(s)} 賣「{lot.name}」\n\n"
182
+ f"介紹:\n> {self.pitch}\n\n"
183
+ + "喊價:" + "、".join(self.bidlog)
184
+ + f"\n\n{result}。揭曉:\n{lot.card()}\n")
185
+ self.sold += 1
186
+ if self.sold < self.n:
187
+ self.seller, self.phase = (s + 1) % self.n, "describe"
188
+ elif self.round < self.rounds:
189
+ self.deal()
190
+ self.phase = "pick"
191
+ self.say(f"## 第 {self.round}/{self.rounds} 輪\n每人拿到 3 件新寶物,請重新選拍品。")
192
+ else:
193
+ self.phase = "over"
194
+ self.say(self.final_result())
195
+
196
+ def todo(self, p):
197
+ if self.phase == "join" and not self.names[p]:
198
+ return "join"
199
+ if self.phase == "pick" and not self.lots[p]:
200
+ return "pick"
201
+ if self.phase == "describe" and p == self.seller:
202
+ return "describe"
203
+ if self.phase == "bid" and p == self.turn:
204
+ return "bid"
205
+ return None
206
+
207
+ def view(self, who) -> dict:
208
+ v = {
209
+ "phase": self.phase,
210
+ "round": self.round,
211
+ "rounds": self.rounds,
212
+ "history": list(self.history),
213
+ "roster": [(self.name(p), self.money[p], len(self.owned[p])) for p in range(self.n)],
214
+ "seller": self.name(self.seller),
215
+ "lot_name": self.lots[self.seller].name if self.phase in ("describe", "bid") else None,
216
+ "pitch": self.pitch if self.phase == "bid" else None,
217
+ "bidding": self.bidding() if self.phase == "bid" else None,
218
+ "min_bid": self.min_bid(),
219
+ }
220
+ if who == "spectator":
221
+ v["lots"] = [f"{self.name(p)}:{t.card()}" for p, t in enumerate(self.lots) if t]
222
+ else:
223
+ v.update(me=who, todo=self.todo(who), money=self.money[who],
224
+ owned=[t.card() for t in self.owned[who]],
225
+ hand=[t.card() for t in self.hands[who]] if self.phase in ("join", "pick") else None,
226
+ options=[t.name for t in self.hands[who]],
227
+ my_lot=self.lots[who].card() if self.lots[who] else None)
228
+ return v
229
+
230
+ def bidding(self):
231
+ lines = ["喊價:" + ("、".join(self.bidlog) or "(還沒有人出價)")]
232
+ if self.leader is not None:
233
+ lines.append(f"目前最高:{self.name(self.leader)} {self.high}")
234
+ lines.append("還在競標:" + "、".join(self.name(b) for b in sorted(self.active)))
235
+ lines.append(f"輪到:{self.name(self.turn)}")
236
+ return "\n".join(lines)
237
+
238
+ def final_result(self):
239
+ scores = [self.money[p] + sum(t.value for t in self.owned[p]) for p in range(self.n)]
240
+ lines = ["# 最終結果", "", "| 玩家 | 金錢 | 寶物價值 | 總分 |", "|---|---|---|---|"]
241
+ for p in sorted(range(self.n), key=lambda p: -scores[p]):
242
+ lines.append(f"| {self.name(p)} | {self.money[p]} | {scores[p] - self.money[p]} | {scores[p]} |")
243
+ best = max(scores)
244
+ return "\n".join(lines + ["", "🏆 獲勝:" + "、".join(self.name(p) for p in range(self.n)
245
+ if scores[p] == best)])
246
+
247
+
248
+ # ---------------- 客戶端:每個資料夾一個 FolderUI ----------------
249
+
250
+ def roster_md(v):
251
+ rows = [f"# 玩家(第 {v['round']}/{v['rounds']} 輪)", "", "| 名字 | 金錢 | 寶物 |", "|---|---|---|"]
252
+ return "\n".join(rows + [f"| {n} | 💰 {m} | 🏺 {k} 件 |" for n, m, k in v["roster"]]) + "\n"
253
+
254
+
255
+ class PlayerUI(FolderUI):
256
+ def __init__(self, path, server, me):
257
+ super().__init__(path)
258
+ self.server, self.me = server, me
259
+ self.view = server.view(me)
260
+
261
+ def receive(self, update):
262
+ self.view = update.view
263
+ for e in update.events:
264
+ self.notify(e)
265
+ if self.view["phase"] == "over":
266
+ self.close()
267
+
268
+ def draw(self):
269
+ v = self.view
270
+ self.file("rules.md", RULES.format(rounds=v["rounds"], step=STEP))
271
+ self.file("players.md", roster_md(v))
272
+ if v["history"]: # 只往後加 → 適合當紀錄
273
+ self.file("history.md", "# 拍賣紀錄\n\n" + "\n".join(v["history"]))
274
+ if v["owned"]:
275
+ self.file("owned.md", "# 你擁有的寶物\n\n" + "\n\n".join(v["owned"]) + "\n")
276
+ if v["hand"]:
277
+ self.file("hand.md", "# 你的寶物(只有你看得到)\n\n" + "\n\n".join(v["hand"]) + "\n")
278
+ if v["my_lot"]:
279
+ self.file("my_lot.md", "# 你的拍品\n\n" + v["my_lot"] + "\n")
280
+ if v["lot_name"]:
281
+ self.file("lot.md", f"# 拍品:{v['lot_name']}\n\n賣家:{v['seller']}\n\n"
282
+ + (f"> {v['pitch']}\n\n{v['bidding']}\n" if v["pitch"] else "(賣家正在寫介紹…)\n"))
283
+
284
+ todo = v["todo"]
285
+ if todo == "join":
286
+ self.button("join", "<名字>")
287
+ self.reply("請輸入名字加入遊戲(規則見 rules.md)。")
288
+ elif todo == "pick":
289
+ self.button("pick", "<寶物名稱>,詳見 hand.md", options=v["options"])
290
+ self.reply(f"第 {v['round']}/{v['rounds']} 輪:選一件寶物拿去拍賣,其餘兩件丟棄。")
291
+ elif todo == "describe":
292
+ self.button("describe", "<介紹文字>(買家只看得到名稱和這段介紹)")
293
+ self.reply(f"輪到你當賣家:\n{v['my_lot']}")
294
+ elif todo == "bid":
295
+ if v["min_bid"] <= v["money"]:
296
+ self.button("bid", f"<金額 {v['min_bid']}-{v['money']}> 公開喊價")
297
+ self.button("pass", "退出這件拍品")
298
+ self.reply(f"輪到你喊價。{v['seller']} 正在拍賣「{v['lot_name']}」:\n> {v['pitch']}\n\n"
299
+ f"{v['bidding']}\n\n你有 💰 {v['money']}。")
300
+
301
+ def action(self, name, args):
302
+ text = " ".join(args).strip()
303
+ if name == "pass":
304
+ return self.server.call(self.me, ("bid", None))
305
+ if not text:
306
+ raise ValueError(f"用法:./{name}.sh <內容>")
307
+ if name == "bid":
308
+ if not text.isdigit():
309
+ raise ValueError("出價必須是正整數")
310
+ return self.server.call(self.me, ("bid", int(text)))
311
+ return self.server.call(self.me, (name, text))
312
+
313
+
314
+ class SpectatorUI(FolderUI):
315
+ """觀眾:看得到所有拍品的真相,沒有按鈕。紀錄寫成只往後加的 log.md。"""
316
+
317
+ def __init__(self, path, server):
318
+ super().__init__(path)
319
+ self.view = server.view("spectator")
320
+ self.log: list[str] = []
321
+
322
+ def receive(self, update):
323
+ self.view = update.view
324
+ self.log += update.events
325
+ if self.view["phase"] == "over":
326
+ self.close()
327
+
328
+ def draw(self):
329
+ v = self.view
330
+ self.file("players.md", roster_md(v))
331
+ if v["lots"]:
332
+ self.file("truth.md", "# 所有拍品的真相\n\n" + "\n\n".join(v["lots"]) + "\n")
333
+ if v["lot_name"]:
334
+ self.file("lot.md", f"# 拍品:{v['lot_name']}\n\n> {v['pitch'] or '(賣家正在寫介紹…)'}\n\n"
335
+ + (v["bidding"] or "") + "\n")
336
+ if self.log:
337
+ self.file("log.md", "".join(e + "\n\n" for e in self.log))
338
+
339
+
340
+ def main():
341
+ ap = argparse.ArgumentParser()
342
+ ap.add_argument("--players", type=int, default=4)
343
+ ap.add_argument("--rounds", type=int, default=1)
344
+ ap.add_argument("--seed", type=int)
345
+ ap.add_argument("--dir")
346
+ args = ap.parse_args()
347
+ if not 2 <= args.players <= 6:
348
+ raise SystemExit("玩家數需為 2-6")
349
+ root = Path(args.dir or tempfile.mkdtemp(prefix="folderui-auction-"))
350
+
351
+ server = AuctionServer(args.players, args.rounds, args.seed)
352
+ clients = {p: PlayerUI(root / f"player_{p}", server, p) for p in range(args.players)}
353
+ clients["spectator"] = SpectatorUI(root / "spectator", server)
354
+ for who, ui in clients.items():
355
+ server.connect(who, ui)
356
+
357
+ server.start()
358
+ threads = [ui.start() for ui in clients.values()]
359
+ for who, ui in clients.items():
360
+ print(f"{who}: {ui.root}", flush=True)
361
+ for t in threads:
362
+ t.join()
363
+ server.stop()
364
+ print(server.final_result())
365
+
366
+
367
+ if __name__ == "__main__":
368
+ main()
@@ -0,0 +1,32 @@
1
+ """拍賣用的隨機 bot(程式型 agent 範例):python examples/auction_bot.py <資料夾> <名字>"""
2
+ import random
3
+ import sys
4
+
5
+ from folderui import Session
6
+
7
+ PITCHES = ["傳說中的寶物,絕對物超所值!", "家族世代相傳,保存完好。", "老實說,我也不確定它值多少。"]
8
+
9
+
10
+ def main(path, name):
11
+ s, rng = Session(path), random.Random()
12
+ s.ready()
13
+ while buttons := s.buttons():
14
+ if "join" in buttons:
15
+ ok, _ = s.press("join", name)
16
+ if not ok: # 名字被用走了
17
+ name += "'"
18
+ elif "pick" in buttons:
19
+ s.press("pick", rng.choice(buttons["pick"]["options"]))
20
+ elif "describe" in buttons:
21
+ s.press("describe", rng.choice(PITCHES))
22
+ elif "bid" in buttons and rng.random() < 0.6:
23
+ ok, _ = s.press("bid", str(rng.randint(5, 60)))
24
+ if not ok: # 低於最低加價或錢不夠,改成退出
25
+ s.press("pass")
26
+ elif "pass" in buttons:
27
+ s.press("pass")
28
+ print(f"{name}: 結束")
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main(*sys.argv[1:3])
@@ -0,0 +1,44 @@
1
+ """單人範例:猜數字。不需要伺服器,一個 FolderUI 就是整個應用。
2
+
3
+ uv run python examples/guess.py [--dir DIR] [--seed N]
4
+ """
5
+ import argparse
6
+ import random
7
+ import tempfile
8
+
9
+ from folderui import FolderUI
10
+
11
+
12
+ class Guess(FolderUI):
13
+ def __init__(self, path, seed=None):
14
+ super().__init__(path)
15
+ self.secret = random.Random(seed).randint(1, 100)
16
+ self.tries: list[tuple[int, str]] = []
17
+
18
+ def draw(self):
19
+ self.file("rules.md", "# 猜數字\n\n我想了一個 1-100 的整數,用 `./guess.sh <數字>` 猜猜看。\n")
20
+ if self.tries: # 只往後加 → 適合當紀錄
21
+ self.file("history.md", "".join(f"- {n}:{r}\n" for n, r in self.tries))
22
+ self.button("guess", "<1-100 的整數>")
23
+ self.reply(f"已經猜了 {len(self.tries)} 次。")
24
+
25
+ def action(self, name, args):
26
+ if len(args) != 1 or not args[0].isdigit() or not 1 <= int(args[0]) <= 100:
27
+ raise ValueError("用法:./guess.sh <1-100 的整數>")
28
+ n = int(args[0])
29
+ result = "🎉 答對了" if n == self.secret else ("太大" if n > self.secret else "太小")
30
+ self.tries.append((n, result))
31
+ if n == self.secret:
32
+ self.close()
33
+ return f"🎉 答對了!答案是 {n},共猜了 {len(self.tries)} 次"
34
+ return f"{n} {result}"
35
+
36
+
37
+ if __name__ == "__main__":
38
+ ap = argparse.ArgumentParser()
39
+ ap.add_argument("--dir")
40
+ ap.add_argument("--seed", type=int)
41
+ args = ap.parse_args()
42
+ ui = Guess(args.dir or tempfile.mkdtemp(prefix="folderui-guess-"), args.seed)
43
+ print(ui.root, flush=True)
44
+ ui.run()
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "folderui"
3
+ version = "0.1.0"
4
+ description = "Turn a folder into an immediate-mode GUI for humans and AI agents: *.md files are the view, *.sh files are buttons."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "xiao-e-yun" }]
9
+ requires-python = ">=3.10"
10
+ dependencies = []
11
+ keywords = ["agent", "llm", "gui", "immediate-mode", "multiplayer", "turn-based"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: POSIX",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Libraries",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/xiao-e-yun/folderui"
23
+ Repository = "https://github.com/xiao-e-yun/folderui"
24
+
25
+ [build-system]
26
+ requires = ["hatchling>=1.27"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.sdist]
30
+ include = ["src", "examples", "tests", "README.md", "LICENSE"]
31
+
32
+ [dependency-groups]
33
+ dev = ["pytest>=8"]
@@ -0,0 +1,6 @@
1
+ """folderui:把資料夾變成給人類與 AI agent 用的立即式 GUI。"""
2
+ from .server import Server
3
+ from .session import Session
4
+ from .ui import FolderUI
5
+
6
+ __all__ = ["FolderUI", "Server", "Session"]
@@ -0,0 +1,28 @@
1
+ """檔案工具:所有寫入都是原子的(先寫暫存檔再 rename),避免讀到半個檔案。"""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def write_text(path: Path, text: str, mode: int | None = None) -> None:
8
+ tmp = path.with_name(f".{path.name}.tmp")
9
+ tmp.write_text(text, encoding="utf-8")
10
+ if mode is not None:
11
+ os.chmod(tmp, mode)
12
+ os.replace(tmp, path)
13
+
14
+
15
+ def write_json(path: Path, data) -> None:
16
+ write_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
17
+
18
+
19
+ def read_text(path: Path, default=None):
20
+ try:
21
+ return path.read_text(encoding="utf-8")
22
+ except FileNotFoundError:
23
+ return default
24
+
25
+
26
+ def read_json(path: Path, default=None):
27
+ text = read_text(path)
28
+ return default if text is None else json.loads(text)
File without changes
@@ -0,0 +1,68 @@
1
+ """多人用的伺服器:一個執行緒依序處理所有客戶端的請求,並推播給客戶端。
2
+
3
+ 客戶端 ──call(who, cmd)──▶ Server.handle() 請求/回應(同步)
4
+ 客戶端 ◀──push(who, msg)── Server 推播(非同步)
5
+
6
+ handle() raise 的例外會傳回呼叫端(ValueError 代表規則不允許)。
7
+ 推播走客戶端的無上限佇列,伺服器永遠不會等待客戶端,所以不會死鎖。
8
+ """
9
+ import queue
10
+ import threading
11
+ import traceback
12
+
13
+
14
+ class Server:
15
+ def __init__(self):
16
+ self.clients: dict = {}
17
+ self._requests: queue.Queue = queue.Queue()
18
+ self._stopped = False
19
+
20
+ # ---- 子類別實作 ----
21
+
22
+ def handle(self, who, cmd):
23
+ raise NotImplementedError
24
+
25
+ def on_start(self) -> None:
26
+ pass
27
+
28
+ # ---- API ----
29
+
30
+ def connect(self, who, client) -> None:
31
+ """註冊一個可以接收推播的客戶端(任何有 push(msg) 的物件)。"""
32
+ self.clients[who] = client
33
+
34
+ def call(self, who, cmd, timeout: float = 30):
35
+ box: queue.Queue = queue.Queue(maxsize=1)
36
+ self._requests.put((who, cmd, box))
37
+ try:
38
+ ok, value = box.get(timeout=timeout)
39
+ except queue.Empty:
40
+ raise TimeoutError("伺服器沒有回應") from None
41
+ if ok:
42
+ return value
43
+ raise value
44
+
45
+ def push(self, who, msg) -> None:
46
+ self.clients[who].push(msg)
47
+
48
+ def start(self) -> threading.Thread:
49
+ t = threading.Thread(target=self.run, name="folderui:server", daemon=True)
50
+ t.start()
51
+ return t
52
+
53
+ def stop(self) -> None:
54
+ self._stopped = True
55
+
56
+ def run(self) -> None:
57
+ self.on_start()
58
+ while not self._stopped:
59
+ try:
60
+ who, cmd, box = self._requests.get(timeout=0.2)
61
+ except queue.Empty:
62
+ continue
63
+ try:
64
+ box.put((True, self.handle(who, cmd)))
65
+ except Exception as e:
66
+ if not isinstance(e, ValueError):
67
+ traceback.print_exc()
68
+ box.put((False, e))
@@ -0,0 +1,44 @@
1
+ """程式型 agent 用的客戶端:和按鈕腳本走同一個檔案協定。"""
2
+ import json
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from .fsutil import read_text, write_text
7
+
8
+
9
+ class Session:
10
+ def __init__(self, path: str | Path, poll: float = 0.1):
11
+ self.root = Path(path)
12
+ self.poll = poll
13
+
14
+ def ready(self, timeout: float | None = None) -> bool:
15
+ """等到資料夾畫好(第一次可以讀到按鈕列表)。"""
16
+ deadline = None if timeout is None else time.monotonic() + timeout
17
+ while not (self.root / ".buttons.json").exists():
18
+ if deadline is not None and time.monotonic() > deadline:
19
+ return False
20
+ time.sleep(self.poll)
21
+ return True
22
+
23
+ def buttons(self) -> dict[str, dict]:
24
+ """目前可按的按鈕 {路徑: {hint, options}};空的代表已結束(或還輪不到你)。"""
25
+ return json.loads(read_text(self.root / ".buttons.json", "{}"))
26
+
27
+ def read(self, path: str) -> str:
28
+ return read_text(self.root / path, "")
29
+
30
+ def press(self, path: str, *args: str, timeout: float | None = None) -> tuple[bool, str]:
31
+ """按下按鈕並等待回應,回傳 (是否成功, 回應內容)。"""
32
+ request = self.root / ".request"
33
+ if request.exists():
34
+ raise RuntimeError("上一個操作還在等待回應")
35
+ (self.root / ".done").unlink(missing_ok=True)
36
+ write_text(request, "".join(f"{x}\0" for x in (path, *args)))
37
+ deadline = None if timeout is None else time.monotonic() + timeout
38
+ while request.exists():
39
+ if deadline is not None and time.monotonic() > deadline:
40
+ raise TimeoutError("等待回應逾時")
41
+ time.sleep(self.poll)
42
+ code, _, text = read_text(self.root / ".response", "1\n").partition("\n")
43
+ (self.root / ".done").unlink(missing_ok=True)
44
+ return code.strip() == "0", text.rstrip("\n")
@@ -0,0 +1,261 @@
1
+ """FolderUI:把一個資料夾變成給人類或 AI agent 用的立即式 GUI。
2
+
3
+ 子類別實作三個方法:
4
+ draw() 每一幀重新宣告畫面:file()、button()、reply()。不可修改狀態。
5
+ action(name, args) 按鈕被按下時呼叫:回傳文字,或 raise 代表失敗。只有這裡修改狀態。
6
+ receive(msg) 收到 push() 的訊息(例如伺服器推播)。
7
+
8
+ 資料夾裡只有 draw() 宣告的檔案和按鈕腳本(<path>.sh)。執行按鈕腳本後,腳本會一直等待,
9
+ 直到「操作處理完之後,第一個有按鈕可按的畫面」出現(或 close())才回應:
10
+ action 的回傳值 + 這段期間的 notify() + reply() + 目前可按的按鈕
11
+
12
+ 腳本與主機之間的協定(隱藏檔):
13
+ .request 腳本寫入:按鈕路徑與參數,以 NUL 分隔
14
+ .response 主機寫入:第一行為 exit code,之後為回應內容
15
+ .done 主機回應後把 .request 改名為 .done;腳本讀完回應後刪除它
16
+ .buttons.json 目前的按鈕列表(給程式型 agent 用)
17
+ """
18
+ import json
19
+ import os
20
+ import queue
21
+ import shlex
22
+ import sys
23
+ import threading
24
+ import time
25
+ import traceback
26
+ from pathlib import Path
27
+
28
+ from .fsutil import write_text
29
+
30
+ PROTOCOL_FILES = (".request", ".request.new", ".response", ".done")
31
+
32
+ SCRIPT = """\
33
+ #!/bin/sh
34
+ # folderui 按鈕:{name}{hint}
35
+ cd {root} || exit 1
36
+ printf '%s\\0' {qname} "$@" > .request.new
37
+ if [ -e .request ]; then
38
+ if cmp -s .request .request.new; then
39
+ rm -f .request.new
40
+ echo "⏳ 繼續等待…" >&2
41
+ else
42
+ rm -f .request.new
43
+ echo "❌ 上一個操作還在等待回應;再執行一次同樣的指令可以繼續等待"
44
+ exit 1
45
+ fi
46
+ elif [ -e .done ] && cmp -s .done .request.new; then
47
+ rm -f .request.new
48
+ else
49
+ rm -f .done .response
50
+ mv .request.new .request
51
+ echo "⏳ 已送出,等待中…" >&2
52
+ fi
53
+ limit=$(( ${{FOLDERUI_TIMEOUT:-0}} * 5 )); i=0
54
+ while [ -e .request ]; do
55
+ sleep 0.2; i=$((i+1))
56
+ if [ "$limit" -gt 0 ] && [ "$i" -ge "$limit" ]; then
57
+ echo "⏳ 還在等待中,再執行一次同樣的指令即可繼續等待"
58
+ exit 2
59
+ fi
60
+ done
61
+ code=$(head -n 1 .response)
62
+ tail -n +2 .response
63
+ rm -f .done
64
+ exit "$code"
65
+ """
66
+
67
+
68
+ def _check_path(path: str) -> str:
69
+ parts = path.split("/")
70
+ if path.startswith("/") or any(not p or p.startswith(".") for p in parts):
71
+ raise ValueError(f"路徑不能是絕對路徑、空段落或隱藏檔:{path!r}")
72
+ return path
73
+
74
+
75
+ class FolderUI:
76
+ poll = 0.1
77
+ close_grace = 5.0
78
+
79
+ def __init__(self, path: str | Path):
80
+ self.root = Path(path).resolve()
81
+ self.root.mkdir(parents=True, exist_ok=True)
82
+ self.closed = False
83
+ self._inbox: queue.Queue = queue.Queue()
84
+ self._files: dict[str, str] = {}
85
+ self._buttons: dict[str, tuple[str, list[str] | None]] = {}
86
+ self._reply = ""
87
+ self._disk_files: dict[str, str] = {} # 磁碟上由本框架管理的檔案
88
+ self._disk_scripts: dict[str, str] = {}
89
+ self._notes: list[str] = []
90
+ self._pending: tuple[int, str] | None = None
91
+ self._pending_button: tuple[str, str] | None = None # (路徑, hint):回應被讀走前保留它的腳本
92
+
93
+ # ---- 子類別實作 ----
94
+
95
+ def draw(self) -> None:
96
+ pass
97
+
98
+ def action(self, name: str, args: list[str]) -> str | None:
99
+ raise ValueError(f"沒有處理按鈕 {name}")
100
+
101
+ def receive(self, msg) -> None:
102
+ pass
103
+
104
+ # ---- 在 draw() 中呼叫 ----
105
+
106
+ def file(self, path: str, content: str) -> None:
107
+ self._files[_check_path(path)] = content
108
+
109
+ def button(self, path: str, hint: str = "", options: list[str] | None = None) -> None:
110
+ self._buttons[_check_path(path)] = (hint, options)
111
+
112
+ def reply(self, text: str) -> None:
113
+ self._reply = text
114
+
115
+ # ---- 在 action() / receive() 中呼叫 ----
116
+
117
+ def notify(self, text: str) -> None:
118
+ """累積一則消息,在下一次回應時一起送出。"""
119
+ self._notes.append(text)
120
+
121
+ def close(self) -> None:
122
+ """結束:移除所有按鈕,回應正在等待的腳本,然後停止。"""
123
+ self.closed = True
124
+
125
+ # ---- 給其他執行緒 ----
126
+
127
+ def push(self, msg) -> None:
128
+ self._inbox.put(msg)
129
+
130
+ def start(self) -> threading.Thread:
131
+ t = threading.Thread(target=self.run, name=f"folderui:{self.root.name}", daemon=True)
132
+ t.start()
133
+ return t
134
+
135
+ # ---- 主迴圈 ----
136
+
137
+ def run(self) -> None:
138
+ for name in PROTOCOL_FILES:
139
+ (self.root / name).unlink(missing_ok=True)
140
+ self._redraw()
141
+ deadline = None
142
+ while not (self.closed and self._pending is None and self._pending_button is None):
143
+ if self.closed and self._pending is None: # 已結束:最後的回應最多等 close_grace 秒被讀走
144
+ deadline = deadline or time.monotonic() + self.close_grace
145
+ if time.monotonic() > deadline:
146
+ break
147
+ changed = False
148
+ try:
149
+ msg = self._inbox.get(timeout=self.poll)
150
+ self._call(self.receive, msg)
151
+ changed = True
152
+ except queue.Empty:
153
+ pass
154
+ changed |= self._take_request()
155
+ changed |= self._drain()
156
+ if self._pending is None and self._pending_button and not (self.root / ".done").exists():
157
+ self._pending_button = None # 回應已被讀走
158
+ changed = True
159
+ if changed:
160
+ self._redraw()
161
+ if self._pending is not None and (self._buttons or self.closed):
162
+ self._respond()
163
+
164
+ def _drain(self) -> bool:
165
+ changed = False
166
+ while True:
167
+ try:
168
+ msg = self._inbox.get_nowait()
169
+ except queue.Empty:
170
+ return changed
171
+ self._call(self.receive, msg)
172
+ changed = True
173
+
174
+ def _take_request(self) -> bool:
175
+ if self._pending is not None:
176
+ return False
177
+ try:
178
+ raw = (self.root / ".request").read_bytes()
179
+ except FileNotFoundError:
180
+ return False
181
+ name, *args = [x.decode("utf-8", "replace") for x in raw.split(b"\0")[:-1]] or [""]
182
+ if name not in self._buttons:
183
+ self._pending = (1, f"❌ 按鈕 {name} 已經不存在")
184
+ return True
185
+ self._pending_button = (name, self._buttons[name][0])
186
+ try:
187
+ result = self.action(name, args)
188
+ self._pending = (0, result or "")
189
+ except Exception as e:
190
+ if not isinstance(e, ValueError): # ValueError 視為使用者錯誤,其餘是程式 bug
191
+ traceback.print_exc()
192
+ self._pending = (1, f"❌ {e}")
193
+ return True
194
+
195
+ def _respond(self) -> None:
196
+ code, text = self._pending
197
+ parts = [text] if text else []
198
+ parts += self._notes
199
+ if self._reply:
200
+ parts.append(self._reply)
201
+ if self.closed:
202
+ parts.append("── 已結束 ──")
203
+ else:
204
+ lines = ["── 可以做的事 ──"]
205
+ for path, (hint, options) in self._buttons.items():
206
+ lines.append(f"./{path}.sh" + (f" {hint}" if hint else ""))
207
+ if options:
208
+ lines.append(" 選項:" + " / ".join(options))
209
+ parts.append("\n".join(lines))
210
+ write_text(self.root / ".response", f"{code}\n" + "\n\n".join(parts) + "\n")
211
+ os.replace(self.root / ".request", self.root / ".done")
212
+ self._notes = []
213
+ self._pending = None
214
+
215
+ def _redraw(self) -> None:
216
+ self._files, self._buttons, self._reply = {}, {}, ""
217
+ self._call(self.draw)
218
+ if self.closed:
219
+ self._buttons = {}
220
+ scripts = {f"{p}.sh": self._script(p, hint) for p, (hint, _) in self._buttons.items()}
221
+ if self._pending_button: # 逾時後重新執行同一個指令=繼續等待或拿回應,所以腳本要留到回應被讀走
222
+ name, hint = self._pending_button
223
+ scripts.setdefault(f"{name}.sh", self._script(name, hint))
224
+ self._sync(self._disk_files, self._files, None)
225
+ self._sync(self._disk_scripts, scripts, 0o755)
226
+ write_text(self.root / ".buttons.json", json.dumps(
227
+ {p: {"hint": h, "options": o} for p, (h, o) in self._buttons.items()},
228
+ ensure_ascii=False, indent=2) + "\n")
229
+
230
+ def _sync(self, disk: dict[str, str], want: dict[str, str], mode: int | None) -> None:
231
+ for path in set(disk) - set(want):
232
+ (self.root / path).unlink(missing_ok=True)
233
+ self._prune(self.root / path)
234
+ del disk[path]
235
+ for path, content in want.items():
236
+ if disk.get(path) != content:
237
+ target = self.root / path
238
+ target.parent.mkdir(parents=True, exist_ok=True)
239
+ write_text(target, content, mode)
240
+ disk[path] = content
241
+
242
+ def _prune(self, path: Path) -> None:
243
+ for d in path.parents:
244
+ if d == self.root:
245
+ return
246
+ try:
247
+ d.rmdir()
248
+ except OSError:
249
+ return
250
+
251
+ def _script(self, name: str, hint: str) -> str:
252
+ hint = " ".join(hint.split())
253
+ return SCRIPT.format(name=name, hint=f" — {hint}" if hint else "",
254
+ root=shlex.quote(str(self.root)), qname=shlex.quote(name))
255
+
256
+ def _call(self, fn, *args) -> None:
257
+ try:
258
+ fn(*args)
259
+ except Exception:
260
+ print(f"[folderui:{self.root.name}] {fn.__name__}() 發生錯誤:", file=sys.stderr)
261
+ traceback.print_exc()
@@ -0,0 +1,166 @@
1
+ import os
2
+ import subprocess
3
+ import threading
4
+ import time
5
+
6
+ import pytest
7
+
8
+ from folderui import FolderUI, Server, Session
9
+
10
+
11
+ class Counter(FolderUI):
12
+ """單人:add 會加一,到 3 就結束。"""
13
+
14
+ def __init__(self, path):
15
+ super().__init__(path)
16
+ self.n = 0
17
+
18
+ def draw(self):
19
+ self.file("count.md", f"{self.n}\n")
20
+ self.button("add", "<1-3>")
21
+ self.reply(f"目前 {self.n}")
22
+
23
+ def action(self, name, args):
24
+ if args != ["1"]:
25
+ raise ValueError("只能加 1")
26
+ self.n += 1
27
+ if self.n == 3:
28
+ self.close()
29
+ return f"加到 {self.n}"
30
+
31
+
32
+ @pytest.fixture
33
+ def counter(tmp_path):
34
+ ui = Counter(tmp_path / "ui")
35
+ thread = ui.start()
36
+ s = Session(ui.root)
37
+ assert s.ready(timeout=5)
38
+ yield ui, s
39
+ ui.close()
40
+ thread.join(timeout=5)
41
+
42
+
43
+ def test_session_press(counter):
44
+ ui, s = counter
45
+ assert set(s.buttons()) == {"add"}
46
+ ok, text = s.press("add", "2", timeout=5)
47
+ assert not ok and "只能加 1" in text
48
+ ok, text = s.press("add", "1", timeout=5)
49
+ assert ok and text.startswith("加到 1") and "./add.sh" in text
50
+ assert s.read("count.md") == "1\n"
51
+
52
+
53
+ def test_close_removes_buttons(counter):
54
+ ui, s = counter
55
+ for _ in range(3):
56
+ ok, text = s.press("add", "1", timeout=5)
57
+ assert ok and "已結束" in text
58
+ assert s.buttons() == {}
59
+ for _ in range(50): # 回應被讀走後,下一幀才會移除保留的腳本
60
+ if not (ui.root / "add.sh").exists():
61
+ break
62
+ time.sleep(0.1)
63
+ assert not (ui.root / "add.sh").exists()
64
+
65
+
66
+ def test_shell_button(counter):
67
+ ui, s = counter
68
+ r = subprocess.run([str(ui.root / "add.sh"), "1"], capture_output=True, text=True, timeout=10)
69
+ assert r.returncode == 0 and "加到 1" in r.stdout
70
+ r = subprocess.run([str(ui.root / "add.sh"), "x"], capture_output=True, text=True, timeout=10)
71
+ assert r.returncode == 1 and "只能加 1" in r.stdout
72
+
73
+
74
+ class ReadyServer(Server):
75
+ """兩個人都按 ready 才一起開始。"""
76
+
77
+ def __init__(self):
78
+ super().__init__()
79
+ self.ready: set = set()
80
+
81
+ def handle(self, who, cmd):
82
+ self.ready.add(who)
83
+ if len(self.ready) == len(self.clients):
84
+ for p in self.clients:
85
+ self.push(p, "go")
86
+ return "✅ 準備好了"
87
+
88
+
89
+ class Lobby(FolderUI):
90
+ def __init__(self, path, server, me):
91
+ super().__init__(path)
92
+ self.server, self.me, self.state = server, me, "lobby"
93
+
94
+ def draw(self):
95
+ if self.state == "lobby":
96
+ self.button("ready")
97
+ elif self.state == "waiting":
98
+ pass # 沒有按鈕 → 腳本繼續等
99
+ else:
100
+ self.button("play")
101
+
102
+ def action(self, name, args):
103
+ if name == "ready":
104
+ self.state = "waiting"
105
+ return self.server.call(self.me, "ready")
106
+ self.close()
107
+ return "played"
108
+
109
+ def receive(self, msg):
110
+ self.state = "started"
111
+ self.notify("🔔 開始了")
112
+
113
+
114
+ def test_multiplayer_blocks_until_everyone_ready(tmp_path):
115
+ server = ReadyServer()
116
+ uis = {p: Lobby(tmp_path / f"p{p}", server, p) for p in range(2)}
117
+ for p, ui in uis.items():
118
+ server.connect(p, ui)
119
+ server.start()
120
+ threads = [ui.start() for ui in uis.values()]
121
+ s0, s1 = Session(uis[0].root), Session(uis[1].root)
122
+ assert s0.ready(timeout=5) and s1.ready(timeout=5)
123
+
124
+ result = {}
125
+ t = threading.Thread(target=lambda: result.update(r=s0.press("ready", timeout=10)))
126
+ t.start()
127
+ t.join(timeout=0.5)
128
+ assert t.is_alive() # 另一個人還沒準備好,p0 的按鈕卡住
129
+
130
+ ok, text = s1.press("ready", timeout=10)
131
+ t.join(timeout=10)
132
+ assert ok and "開始了" in text and "./play.sh" in text
133
+ ok, text = result["r"]
134
+ assert ok and "開始了" in text
135
+
136
+ for s in (s0, s1):
137
+ s.press("play", timeout=10)
138
+ for th in threads:
139
+ th.join(timeout=5)
140
+ server.stop()
141
+
142
+
143
+ def test_timeout_and_resume(tmp_path):
144
+ server = ReadyServer()
145
+ uis = {p: Lobby(tmp_path / f"p{p}", server, p) for p in range(2)}
146
+ for p, ui in uis.items():
147
+ server.connect(p, ui)
148
+ server.start()
149
+ for ui in uis.values():
150
+ ui.start()
151
+ assert Session(uis[0].root).ready(timeout=5)
152
+ script = str(uis[0].root / "ready.sh")
153
+ env = {**os.environ, "FOLDERUI_TIMEOUT": "1"}
154
+
155
+ r = subprocess.run([script], capture_output=True, text=True, timeout=10, env=env)
156
+ assert r.returncode == 2 # 逾時,但請求已送出
157
+
158
+ r = subprocess.run([script, "other"], capture_output=True, text=True, timeout=10, env=env)
159
+ assert r.returncode == 1 and "還在等待" in r.stdout # 不同的指令會被拒絕
160
+
161
+ Session(uis[1].root).press("ready", timeout=10)
162
+ r = subprocess.run([script], capture_output=True, text=True, timeout=10, env=env)
163
+ assert r.returncode == 0 and "開始了" in r.stdout # 同樣的指令 → 拿到回應
164
+ for ui in uis.values():
165
+ ui.close()
166
+ server.stop()