接續昨天的內容,我們繼續來完成做到一半的刪除圖片吧!
昨天有說到,按下 ctrl + O 後,會有幾個情況:
return。questionary.select() 來做選單了,然後也加上 ctrl + O 預覽的功能。前兩個已經完成了,今天首先來做比較麻煩的第三點。
這裡迴圈的邏輯,直接設為 while attached_images,也就是說,只要還有圖片就會繼續跳出選單。repeat 的部分是因為,在結束選擇的部分,我想做出區別,如果是首次跳出選單會顯示「取消」,如果已經是第二次或以上則會顯示「不繼續選擇」(此邏輯放在等等 questionary.select() 的參數部分)。
接下來,用 for 取出圖片路徑,然後又用到了 questionary.Choice() 來區別出顯示及賦值(把顯示部分只保留檔名及副檔名):
# src/meowgent/cli/input_prompt.py
...
async def _del_image():
...
if ...:
...
elif ...:
...
else:
repeat = False # 標記是否為重複選擇了
while attached_images: # 刪光時離開
img_list = []
for img in attached_images:
img_list.append(questionary.Choice(
title=Path(img).name,
value=img
))
這邊看一下渲染出的選單效果大致如下圖:
用到了 questionary.select() 但有注意到嗎?怎麼沒有 ask() 呢?
這是因為我們還有 ctrl + O 的偵測要跑,所以不能馬上送出渲染,而是等邏輯偵測跑完才送。
最後,把 repeat 設為 True 標記為已經選擇至少一次,來為下一輪選單的 ["不繼續選擇"] if repeat else ["取消"] 選項切換做準備。
# src/meowgent/cli/input_prompt.py
...
async def _del_image():
...
while ...:
...
q = questionary.select(
"選擇要刪除的圖片",
choices=img_list + (["不繼續選擇"] if repeat else ["取消"]),
instruction="(Enter: 刪除, Ctrl+O: 開啟圖片)",
style=Style([
("question", "dim"),
("instruction", "dim")
])
)
repeat = True
接下來開始,是我個人覺得整份專案進行到現在遇到最難的部分。
首先,我們要取得 Question 物件控制項之中的「選單控制器物件」:
解釋一下語法,q 是前面 questionary.select() 沒有 ask() 所得到的「Question 物件」,生成式遍歷 q.application.layout.find_all_controls()(所有的控制項),其中我們要的只有屬於 InquirerControl 物件的控制項(選單控制器物件),
而整個生成式用 next() 去取值。
說一下
next(),它的作用是獲取迭代器的「下一個」元素,像是函數的yield回傳、生成式的值。
如果覺得這樣寫不太懂,其實等同於,用串列生成式,然後取出第一個元素:ic = [c for c in q.application.layout.find_all_controls() if isinstance(c, InquirerControl)][0]
# src/meowgent/cli/input_prompt.py
...
async def _del_image():
...
while ...:
...
ic = next(
c for c in q.application.layout.find_all_controls() if isinstance(
c,
InquirerControl
)
)
得到選單控制器物件後,接著就可以來做按鍵捕捉到要執行的函數:
這裡套上 @q.application.key_bindings.add() 函數裝飾器,這裡是運用了 Question 物件裡的 application 屬性(得到 Application 物件)之中的 key_bindings 屬性來連結到 KeyBindings 物件,然後對 KeyBindings 物件做 add() 來註冊快捷鍵。
參數部分,
c-o指的就是 ctrl + O,eager則是是否要在快捷鍵「匹配成功時立刻執行」,也就是不等其他更長的快捷鍵出現(例如 ctrl + O + 空格 等的組合,當沒有更複雜的快捷鍵時設為True可以減少等待時間)。
而 _open_preview() 就是當捕捉到快捷鍵要執行的函數,而傳入的 event 是 KeyPressEvent 物件,這邊可以暫時不用知道它是做什麼的,因為現在還不會用到。
雖然不會用到,但是一定要傳入,這是被
@key_bindings.add()裝飾之函數的必選參數。
它代表當前的按鍵觸發事件,讓你可以取得終端當下的上下文資訊(輸入框裡已輸入的文字、游標所在位置,以及選單目前反白停留在哪一個項目),並直接控制輸入框內容或應用程式的狀態(例如強制退出)。
# src/meowgent/cli/input_prompt.py
...
async def _del_image():
...
while ...:
...
@q.application.key_bindings.add("c-o", eager=True)
def _open_preview(event):
進到 _open_preview() 後,首先要知道當前游標所停留的圖片是哪張,這邊就用到了前面拿的「選單控制器物件 - ic」,對此物件 get_pointed_at() 後就得到當前的選擇,
但還記得嗎,前面有對選項做 questionary.Choice() 所以這邊需要特別去取出 value 才是所選圖片的路徑。
其實,就算沒有做
questionary.Choice()也需要多一個步驟,只不過value也可以用title替代,
這是因為get_pointed_at()會自動將選項封裝為 Choice 物件,而沒經過questionary.Choice()時value和title是相同的。
# src/meowgent/cli/input_prompt.py
...
async def _del_image():
...
while ...:
...
@...
def _open_preview(...):
current_img = ic.get_pointed_at().value
然後來做開啟圖片的部分,這邊要先確定選到的不是「不繼續選擇」或「取消」,然後用到非同步運行 open 路徑 的指令:
注意,這個指令是 macOS 專屬的。
# src/meowgent/cli/input_prompt.py
...
async def _del_image():
...
while ...:
...
@...
def _open_preview(...):
...
if current_img not in ("不繼續選擇", "取消"):
subprocess.Popen(["open", current_img])
接著就可以做選項的賦值了,透過 await 呼叫非同步的 ask_async():
表示這邊先以「非同步方式顯示選單並等待操作」,直到使用者按下 Enter 將選取的圖片結果存入
del_img。
# src/meowgent/cli/input_prompt.py
...
async def _del_image():
...
while ...:
...
@...
def _open_preview(...):
...
del_img = await q.ask_async()
得到選擇後,判斷是否有選(沒選則 return),有選的話就對所選做 remove(),然後用 _render_del_msg() 顯示刪除結果:
# src/meowgent/cli/input_prompt.py
...
async def _del_image():
...
while ...:
...
@...
def _open_preview(...):
...
if del_img in ("不繼續選擇", "取消", None):
return
attached_images.remove(del_img)
_render_del_msg()
看到這裡,如果你還對整個流程一頭霧水的話,來看看下面這張流程圖:
1. 進入 _del_image() 的 while 迴圈
│
2. 建立選單物件 (q = questionary.select(...))
│
3. 動態掛載快捷鍵 (@q.application.key_bindings.add("c-o"))
└─ 告訴 prompt_toolkit:等一下要是有人按 Ctrl+O,就去跑 _open_preview()
│
4. 啟動互動選單 (await q.ask_async())
├─ 終端機畫面畫出清單,進入等待使用者操作的狀態
│
├─>【分支 A:使用者按上下鍵】
│ 終端機即時移動高亮游標,選單繼續等待。
│
├─>【分支 B:使用者按下 Ctrl + O】
│ 1. 觸發 _open_preview(event)
│ 2. 抓出目前游標反白停在哪一張圖片
│ 3. subprocess.Popen 背景打開圖片(Mac 預覽程式彈出)
│ 4. 函式瞬間結束,終端機完全沒被卡住,圖片與選單同時留在畫面上!
│
└─>【分支 C:使用者按下 Enter】
1. await q.ask_async() 終於完成並回傳選擇的項目 (del_img)
2. 從暫存清單中將這張圖片移除
3. 迴圈檢查如果還有圖片,繼續下一輪選單;若無或選取消,退出迴圈。
剛剛已經完成了在圖片選單中的快捷鍵捕捉,接下來要做的是開啟選單(調用 _del_image())的 ctrl + O。
前面的 KeyBindings 物件是從 q.application 取得的,
但這邊,我們是要放在 PromptSession 物件上,而它沒有提供 KeyBindings 物件,所以我們要自行建立:
# src/meowgent/cli/input_prompt.py
def get_input() -> ...:
...
if _prompt_session is None:
kb = KeyBindings()
然後 add() 註冊快捷鍵,catch_del_img 被設為「非同步」,這是因為當前已經有一個元素佔據畫面(PromptSession 輸入框),而 ctrl + O 後會出現第二個 questionary.select(),所以必須用非同步方式。
然後要記得寫
event。
in_terminal() 是用於把目前正在運行的 PromptSession 或 Application(例如 questionary 問答)掛起當前介面,顯示第二個互動介面,
而由於它屬於「非同步上下文管理器物件」所以要搭配「非同步上下文管理器 async with」使用。
為什麼是非同步呢?
這是因為新視窗(questionary)要等待舊視窗(PromptSession)交出鍵盤控制權,以及結束時等待螢幕重繪任務。為什麼是上下文管理器?
可以做到安全的進入以及離開新視窗。
而管理器裡的 await _del_image() 就是把控制權交給 _del_image(),接著其中的 del_img = await q.ask_async() 又交給 Questionary 的互動選單,而最後,控制權交到了「Event Loop」手上,也就是你的方向鍵去選擇圖片。
當選單操作結束後,控制權又回到了 PromptSession 手上,這時因為剛剛執行了刪除圖片的動作(但也可能沒刪),所以我們要用 event.app.invalidate() 刷新原先 PromptSession 視窗底下的提示(bottom_toolbar),把被刪除掉的圖片顯示移除。
# src/meowgent/cli/input_prompt.py
def get_input() -> ...:
...
if _prompt_session is None:
...
@kb.add("c-o", eager=True) # 捕捉 ctrl o
async def catch_del_img(event):
async with in_terminal():
await _del_image()
event.app.invalidate() # 重繪畫面(針對底下圖片提示)
把建立的 key_bindings 物件傳入 PromptSession() 的參數,就大功告成啦!
# src/meowgent/cli/input_prompt.py
def get_input() -> ...:
...
if _prompt_session is None:
_prompt_session = PromptSession(..., key_bindings=kb)
...
這邊,一樣整理了流程:
[目前執行緒只有一個 Event Loop]
1. 主輸入框正在等待按鍵...
↓
2. 收到 Ctrl+O,排入 catch_del_img Task
↓
3. async with in_terminal()
└─ await 等待終端機將游標與螢幕狀態回報並釋放
↓
4. await _del_image()
└─ 暫停當前協程,把 Event Loop 的處理權讓給 Questionary 渲染與監聽按鍵
↓ (使用者在選單中操作並按 Enter)
5. _del_image 結束,喚醒原協程
↓
6. 離開 with,終端機復原
↓
7. event.app.invalidate()
└─ 同步標記「畫面髒了(Dirty)」,告訴 Event Loop 在下一個 Tick 重繪主畫面
哇!今天的內容真的是有夠複雜,尤其是非同步真的搞得我頭很痛,但好在,還是順利搞定了!
明天,要開始做一個很有趣的功能 - subagent,也就是讓主模型可以呼叫子模型做事,像老闆分配工作給員工一樣。