Skip to content

Commit 0a1ad68

Browse files
committed
Playground 升级为整站级页面(顶栏入口),REPL 改为终端式直接输入
1 parent a1cdbf7 commit 0a1ad68

4 files changed

Lines changed: 157 additions & 92 deletions

File tree

.vitepress/config.mts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export default defineConfig({
3030
// https://vitepress.dev/reference/default-theme-config
3131
nav: [
3232
{ text: '首页', link: '/' },
33-
{ text: '大纲', link: '/objects/object/' }
33+
{ text: '大纲', link: '/objects/object/' },
34+
{ text: 'Playground', link: '/playground/' }
3435
],
3536

3637
sidebar: [
Lines changed: 127 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<script setup>
2-
import { ref, shallowRef, nextTick } from 'vue'
2+
import { ref, nextTick } from 'vue'
33
import TOY_SRC from '../../practice/mini-vm/minivm.py?raw'
44
import { getPyodide } from './pyodide'
55
66
// 注入到 Pyodide 的「驱动」:把编辑后的 VM 源码 exec 进独立命名空间,
7-
// 并维护一个跨 REPL 输入持久的 glob。这部分是固定的,不随用户编辑而变。
7+
// 并维护一个跨 REPL 输入持久的 glob。这部分固定,不随用户编辑而变。
88
const DRIVER = `
99
import json
1010
@@ -46,85 +46,125 @@ def repl_eval(src):
4646
`
4747
4848
const vmSource = ref(TOY_SRC)
49-
const replInput = ref('1 + 2 * 3')
50-
const history = shallowRef([]) // [{kind:'in'|'out'|'err'|'muted', text}]
51-
const status = ref('idle') // idle | loading | ready | error
52-
const statusMsg = ref('')
5349
const vmError = ref('')
54-
const py = shallowRef(null)
55-
const historyBox = ref(null)
50+
const status = ref('idle') // idle | loading | ready | error
51+
const py = ref(null)
52+
53+
// 终端式 REPL 状态
54+
const term = ref([]) // 已提交的行 [{kind:'in'|'out'|'err'|'muted', text}]
55+
const pendingLines = ref([]) // 当前未执行完的块(多行 def/if/while)
56+
const current = ref('') // 活动输入行
57+
const busy = ref(false)
58+
const termRef = ref(null)
59+
const inputRef = ref(null)
60+
let cmdHistory = []
61+
let histIdx = 0
5662
5763
async function ensureDriver() {
5864
if (py.value) return py.value
5965
status.value = 'loading'
60-
statusMsg.value = '正在加载 Python 运行环境(Pyodide,首次约数 MB)…'
6166
const p = await getPyodide()
6267
p.runPython(DRIVER)
6368
py.value = p
6469
status.value = 'ready'
65-
statusMsg.value = ''
6670
return p
6771
}
6872
69-
async function applyVM() {
73+
async function loadVM(quiet) {
7074
vmError.value = ''
71-
try {
72-
const p = await ensureDriver()
73-
const res = JSON.parse(p.globals.get('load_vm')(vmSource.value))
74-
if (!res.ok) { vmError.value = res.error; status.value = 'error'; return false }
75-
status.value = 'ready'
76-
history.value = [...history.value, { kind: 'muted', text: '— 已应用 VM 源码,会话已重置 —' }]
77-
await scrollDown()
78-
return true
79-
} catch (e) {
80-
vmError.value = String(e); status.value = 'error'; return false
75+
const p = await ensureDriver()
76+
const res = JSON.parse(p.globals.get('load_vm')(vmSource.value))
77+
if (!res.ok) { vmError.value = res.error; status.value = 'error'; return false }
78+
status.value = 'ready'
79+
if (!quiet) {
80+
term.value = [{ kind: 'muted', text: '— 已应用 VM 源码,会话已重置 —' }]
81+
pendingLines.value = []
82+
current.value = ''
8183
}
84+
return true
8285
}
8386
84-
async function runRepl() {
85-
const src = replInput.value
86-
if (!src.trim()) return
87-
// 首次运行时若还没应用过 VM,自动应用一次
88-
if (!py.value || py.value.globals.get('_vm_ns') == null) {
89-
const ok = await applyVM()
90-
if (!ok) return
91-
}
92-
const lines = src.split('\n')
93-
const shown = lines.map((l, i) => (i === 0 ? '>>> ' : '... ') + l).join('\n')
94-
const entries = [...history.value, { kind: 'in', text: shown }]
95-
try {
96-
const res = JSON.parse(py.value.globals.get('repl_eval')(src))
97-
if (res.ok) {
98-
if (res.output) entries.push({ kind: 'out', text: res.output })
99-
else entries.push({ kind: 'muted', text: '(已执行,无输出)' })
100-
} else {
101-
entries.push({ kind: 'err', text: res.error })
102-
}
103-
} catch (e) {
104-
entries.push({ kind: 'err', text: String(e) })
105-
}
106-
history.value = entries
107-
replInput.value = ''
108-
await scrollDown()
87+
async function applyVM() {
88+
try { await loadVM(false) } catch (e) { vmError.value = String(e); status.value = 'error' }
89+
await scrollDown(); focusInput()
10990
}
11091
92+
function resetVM() { vmSource.value = TOY_SRC; vmError.value = '' }
93+
11194
async function clearSession() {
11295
if (py.value) py.value.runPython('reset_session()')
113-
history.value = []
96+
term.value = []; pendingLines.value = []; current.value = ''
97+
focusInput()
11498
}
11599
116-
function resetVM() {
117-
vmSource.value = TOY_SRC
118-
vmError.value = ''
100+
async function runStatement(lines) {
101+
// 把输入回显进终端
102+
const echoed = lines.map((l, i) => ({ kind: 'in', text: (i === 0 ? '>>> ' : '... ') + l }))
103+
term.value = [...term.value, ...echoed]
104+
if (lines.length === 1 && lines[0].trim()) cmdHistory.push(lines[0]) // 仅单行进历史
105+
histIdx = cmdHistory.length
106+
busy.value = true
107+
await scrollDown()
108+
try {
109+
if (!py.value || py.value.globals.get('_vm_ns') == null) {
110+
term.value = [...term.value, { kind: 'muted', text: '(首次运行:正在加载 Python 运行环境,请稍候…)' }]
111+
await scrollDown()
112+
const ok = await loadVM(true)
113+
if (!ok) { term.value = [...term.value, { kind: 'err', text: vmError.value }]; busy.value = false; return }
114+
}
115+
const res = JSON.parse(py.value.globals.get('repl_eval')(lines.join('\n')))
116+
if (res.ok) {
117+
if (res.output) term.value = [...term.value, { kind: 'out', text: res.output }]
118+
} else {
119+
term.value = [...term.value, { kind: 'err', text: res.error }]
120+
}
121+
} catch (e) {
122+
term.value = [...term.value, { kind: 'err', text: String(e) }]
123+
}
124+
busy.value = false
125+
await scrollDown(); focusInput()
119126
}
120127
121-
function onKey(e) {
122-
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); runRepl() }
128+
function onKeydown(e) {
129+
if (e.isComposing) return // 输入法组合中,别误触发
130+
if (e.key === 'Enter') {
131+
e.preventDefault()
132+
if (busy.value) return
133+
const line = current.value
134+
current.value = ''
135+
const starting = pendingLines.value.length === 0
136+
if (starting && line.trim() === '') return
137+
if (starting && !line.trim().endsWith(':')) {
138+
runStatement([line])
139+
} else if (starting) { // 以冒号结尾 → 进入续行
140+
pendingLines.value = [line]
141+
} else if (line.trim() === '') { // 块中空行 → 执行整块
142+
const blk = pendingLines.value.slice()
143+
pendingLines.value = []
144+
runStatement(blk)
145+
} else {
146+
pendingLines.value = [...pendingLines.value, line]
147+
}
148+
scrollDown()
149+
} else if (e.key === 'ArrowUp') {
150+
if (pendingLines.value.length === 0 && cmdHistory.length) {
151+
e.preventDefault()
152+
histIdx = Math.max(0, histIdx - 1)
153+
current.value = cmdHistory[histIdx] ?? ''
154+
}
155+
} else if (e.key === 'ArrowDown') {
156+
if (pendingLines.value.length === 0 && cmdHistory.length) {
157+
e.preventDefault()
158+
histIdx = Math.min(cmdHistory.length, histIdx + 1)
159+
current.value = cmdHistory[histIdx] ?? ''
160+
}
161+
}
123162
}
124163
164+
function focusInput() { nextTick(() => inputRef.value && inputRef.value.focus()) }
125165
async function scrollDown() {
126166
await nextTick()
127-
const el = historyBox.value
167+
const el = termRef.value
128168
if (el) el.scrollTop = el.scrollHeight
129169
}
130170
</script>
@@ -146,31 +186,31 @@ async function scrollDown() {
146186
<div v-if="vmError" class="pg-error">⚠ {{ vmError }}</div>
147187
</div>
148188
149-
<!-- 右:REPL -->
189+
<!-- 右:终端式 REPL(直接输入) -->
150190
<div class="pg-col">
151191
<div class="pg-head">
152192
<span class="pg-title">交互式 REPL(用你改过的 VM 执行)</span>
153193
<span class="pg-spacer" />
154194
<button class="pg-btn" @click="clearSession" title="清空历史并重置变量">清空会话</button>
155195
</div>
156-
<div ref="historyBox" class="pg-history">
157-
<div v-if="!history.length" class="pg-muted">
158-
在下方输入一句代码,回车或点「运行」。支持赋值、算术/比较、if/while、def/return(含递归)、print。
159-
<br />纯表达式会自动回显结果(如 <code>1 + 2 * 3</code> → <code>7</code>)。
196+
<div ref="termRef" class="pg-term" @click="focusInput">
197+
<div v-if="!term.length && !pendingLines.length" class="pg-muted">
198+
直接在这里输入,回车执行——就像一个 Python REPL<br />
199+
支持赋值、算术 / 比较、if / while、def / return(含递归)、print;纯表达式回显结果。
200+
</div>
201+
<div v-for="(h, i) in term" :key="i" class="pg-line" :class="'pg-' + h.kind">{{ h.text }}</div>
202+
<div v-for="(l, i) in pendingLines" :key="'p' + i" class="pg-line pg-in">{{ (i === 0 ? '>>> ' : '... ') + l }}</div>
203+
<div class="pg-active">
204+
<span class="pg-prompt">{{ pendingLines.length ? '... ' : '>>> ' }}</span>
205+
<input ref="inputRef" v-model="current" class="pg-cmd" spellcheck="false"
206+
autocomplete="off" autocapitalize="off" :disabled="busy" @keydown="onKeydown" />
160207
</div>
161-
<div v-for="(h, i) in history" :key="i" class="pg-line" :class="'pg-' + h.kind">{{ h.text }}</div>
162-
</div>
163-
<div class="pg-replbar">
164-
<textarea v-model="replInput" class="pg-input" spellcheck="false" rows="2"
165-
placeholder="输入代码,⌘/Ctrl + Enter 运行" @keydown="onKey"></textarea>
166-
<button class="pg-btn pg-run" :disabled="status === 'loading'" @click="runRepl">运行 ▶</button>
167208
</div>
168-
<div v-if="status === 'loading'" class="pg-muted">{{ statusMsg }}</div>
169209
</div>
170210
</div>
171211
<div class="pg-tip">
172-
💡 试试改造虚拟机:在左侧给 <code>BINARY_OP</code> 加一个新运算符、或新增一条指令,点「应用并重载 VM」,
173-
再到右侧 REPL 验证效果。改坏了点「还原默认」即可。
212+
💡 试试改造虚拟机:在左侧给 <code>_binop</code> 加一个新运算符、或新增一条指令,点「应用并重载 VM」,
213+
再到右侧 REPL 里直接敲代码验证。改坏了点「还原默认」即可。
174214
</div>
175215
</div>
176216
</template>
@@ -180,7 +220,8 @@ async function scrollDown() {
180220
border: 1px solid var(--vp-c-divider);
181221
border-radius: 12px;
182222
padding: 14px;
183-
margin: 18px 0;
223+
margin: 18px auto;
224+
max-width: 1100px;
184225
background: var(--vp-c-bg-soft);
185226
font-size: 13px;
186227
}
@@ -195,32 +236,33 @@ async function scrollDown() {
195236
}
196237
.pg-btn:hover:not(:disabled) { border-color: var(--vp-c-brand-1); color: var(--vp-c-brand-1); }
197238
.pg-btn:disabled { opacity: 0.45; cursor: not-allowed; }
198-
.pg-apply, .pg-run { background: var(--vp-c-brand-1); color: #fff; border-color: var(--vp-c-brand-1); font-weight: 600; }
199-
.pg-apply:hover:not(:disabled), .pg-run:hover:not(:disabled) { background: var(--vp-c-brand-2); color: #fff; }
239+
.pg-apply { background: var(--vp-c-brand-1); color: #fff; border-color: var(--vp-c-brand-1); font-weight: 600; }
240+
.pg-apply:hover:not(:disabled) { background: var(--vp-c-brand-2); color: #fff; }
200241
.pg-src {
201-
width: 100%; box-sizing: border-box; height: 360px; resize: vertical;
242+
width: 100%; box-sizing: border-box; height: 420px; resize: vertical;
202243
font-family: var(--vp-font-family-mono, monospace); font-size: 12px; line-height: 1.5;
203244
padding: 10px; border-radius: 8px; border: 1px solid var(--vp-c-divider);
204245
background: var(--vp-c-bg); color: var(--vp-c-text-1); white-space: pre; overflow: auto;
205246
}
206-
.pg-history {
207-
height: 300px; overflow: auto; padding: 10px; border-radius: 8px;
208-
border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg);
209-
font-family: var(--vp-font-family-mono, monospace); font-size: 12.5px; line-height: 1.55;
247+
.pg-term {
248+
height: 420px; box-sizing: border-box; overflow: auto; cursor: text;
249+
padding: 10px 12px; border-radius: 8px; border: 1px solid var(--vp-c-divider);
250+
background: var(--vp-c-bg); color: var(--vp-c-text-1);
251+
font-family: var(--vp-font-family-mono, monospace); font-size: 12.5px; line-height: 1.6;
210252
}
211-
.pg-line { white-space: pre-wrap; }
253+
.pg-line { white-space: pre-wrap; word-break: break-word; }
212254
.pg-in { color: var(--vp-c-text-1); }
213255
.pg-out { color: var(--vp-c-brand-1); }
214-
.pg-err { color: var(--vp-c-danger-1); }
256+
.pg-err { color: var(--vp-c-danger-1); white-space: pre-wrap; }
215257
.pg-muted { color: var(--vp-c-text-3); }
216-
.pg-replbar { display: flex; gap: 8px; margin-top: 8px; align-items: stretch; }
217-
.pg-input {
218-
flex: 1; box-sizing: border-box; resize: vertical;
219-
font-family: var(--vp-font-family-mono, monospace); font-size: 12.5px;
220-
padding: 8px 10px; border-radius: 8px; border: 1px solid var(--vp-c-divider);
221-
background: var(--vp-c-bg); color: var(--vp-c-text-1);
258+
.pg-active { display: flex; align-items: baseline; }
259+
.pg-prompt { color: var(--vp-c-text-3); white-space: pre; flex: none; }
260+
.pg-cmd {
261+
flex: 1; min-width: 0; border: none; outline: none; background: transparent;
262+
font-family: inherit; font-size: inherit; line-height: inherit;
263+
color: var(--vp-c-text-1); caret-color: var(--vp-c-brand-1); padding: 0;
222264
}
223-
.pg-run { align-self: stretch; white-space: nowrap; }
265+
.pg-cmd:disabled { color: var(--vp-c-text-3); }
224266
.pg-error {
225267
margin-top: 8px; padding: 8px 12px; border-radius: 6px;
226268
background: var(--vp-c-danger-soft); color: var(--vp-c-danger-1); font-size: 12.5px; white-space: pre-wrap;
@@ -233,6 +275,6 @@ async function scrollDown() {
233275
.pg-tip code, .pg-muted code { font-family: var(--vp-font-family-mono, monospace); color: var(--vp-c-brand-1); }
234276
@media (max-width: 720px) {
235277
.pg-cols { grid-template-columns: 1fr; }
236-
.pg-src { height: 240px; }
278+
.pg-src, .pg-term { height: 280px; }
237279
}
238280
</style>

playground/index.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
layout: page
3+
title: Playground
4+
description: 在线改造并运行迷你 Python 虚拟机
5+
sidebar: false
6+
aside: false
7+
---
8+
9+
<div class="pg-page">
10+
11+
# 迷你 Python 虚拟机 · Playground
12+
13+
左边是这台用 Python 写成的迷你虚拟机的**完整源码**,可以直接编辑;右边是一个**终端式 REPL**——直接在里面输入代码、回车执行,用你**改过的**虚拟机来跑,立刻看到效果。配套讲解见[实战章节:动手写一个迷你 Python 虚拟机](/practice/mini-vm/)
14+
15+
<ClientOnly>
16+
<MiniVMPlayground />
17+
</ClientOnly>
18+
19+
</div>
20+
21+
<style>
22+
.pg-page { max-width: 1180px; margin: 0 auto; padding: 32px 24px 64px; }
23+
.pg-page h1 { font-size: 1.6rem; font-weight: 700; margin-bottom: 12px; }
24+
.pg-page > p { color: var(--vp-c-text-2); line-height: 1.7; margin-bottom: 8px; }
25+
.pg-page a { color: var(--vp-c-brand-1); font-weight: 500; }
26+
</style>

practice/mini-vm/index.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,13 @@ while frames:
173173

174174
上面的组件让你跑「****虚拟机执行的代码」;而真正的乐趣,是**改虚拟机本身**。下面这个 Playground,左边是这台迷你虚拟机的**完整源码**(可直接编辑),右边是一个 **REPL**——用你**改过的**虚拟机来执行,立刻验证效果。
175175

176-
改完源码点「应用并重载 VM」,再到右侧 REPL 里敲代码。几个上手实验:
176+
👉 打开 [**Playground**](/playground/)(顶部导航栏也有入口):左边直接编辑这台虚拟机的源码,右边是一个**终端式 REPL**,用你改过的虚拟机即时运行验证。几个上手实验:
177177

178178
- **加一个运算符**:在 `BINARY_OP``_binop` 里加一行,让某个符号有新含义;
179179
- **新增一条指令**:定义一个新 op,在编译器某处 `emit` 它、在虚拟机 `run` 里加一个分支处理它;
180180
- **改改报错信息**,或在 `PRINT` 分支里给输出加个前缀——再在 REPL 里看变化。
181181

182-
<ClientOnly>
183-
<MiniVMPlayground />
184-
</ClientOnly>
185-
186-
> REPL 的变量与函数定义在多次输入间保留;点「应用并重载 VM」或「清空会话」会重置这些变量。这其实就是本章 `minivm.py` 命令行 REPL 的网页版——同一套 `execute()` 在背后驱动。
182+
> Playground 的 REPL 和本章 `minivm.py` 的命令行 REPL 是同一套 `execute()` 在背后驱动——变量与函数定义在多次输入间保留,「应用并重载 VM」或「清空会话」会重置。
187183
188184
## 旁注:看看真实的 CPython 字节码
189185

0 commit comments

Comments
 (0)