Skip to content

Commit 4d9d350

Browse files
ZDStudiosclaude
andcommitted
Click a loose block to run it, with the output in a bubble
Trying one idea meant building a whole script under a hat and pressing the green flag. Now a block lying loose on the canvas is a scratch pad. - click a loose block or stack and it runs from there down - click a reporter and the bubble shows its value - click a hat and its whole script runs - blocks inside a script under a hat are left alone, so nothing runs by accident while you are building The piece is compiled the same way the tab is, carrying its imports, helper functions, variable starting values and any custom block definitions, so a loose call to your own block works. It runs in the project folder with the project interpreter, so file paths and installed packages behave. Anything still going after fifteen seconds is stopped. A mistake is explained rather than dumped: the bubble shows the last line, ZeroDivisionError and friends, and the full traceback goes to the console along with everything that was printed. Version 1.2.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b0ed183 commit 4d9d350

8 files changed

Lines changed: 239 additions & 115 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ scratchpy_selftest/
1616
/main.py
1717
/imported*.py
1818
/pasted*.py
19+
_scratchpy_piece.py
1920

2021
# Build output
2122
build/

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ and hand in.
4444
| 🧩 **148 blocks** | Hat blocks, C-shaped loops, hexagonal booleans, reporter ovals that drop into slots — the real Scratch 3 shapes and colours |
4545
| 🐍 **Real Python, live** | The generated source updates as you drag. No hidden interpreter |
4646
| ▶️ **It actually runs** | `print`, `input`, errors and a stop button, all wired to the built-in console |
47+
| 👆 **Click a block to try it** | A loose block runs on its own and reports what it printed in a bubble underneath |
4748
| 🌐 **Talks to the web out of the box** | GET, POST, headers, JSON, downloads — using `urllib` from the standard library, so there is nothing to install |
4849
| 📥 **Import any `.py`** | Turn a program you already have into blocks — loops, functions, try/except, f-strings and all |
4950
| 📦 **Every PyPI package** | pip dashboard installs anything and turns it into blocks automatically |
@@ -52,6 +53,30 @@ and hand in.
5253

5354
---
5455

56+
## Click a block to try it
57+
58+
Blocks lying loose on the canvas are a scratch pad. Click one and it runs on its
59+
own, with a little bubble underneath showing what it printed.
60+
61+
<div align="center">
62+
<img src="docs/screenshot-click.png" width="900" alt="Clicking a loose stack shows its output in a bubble">
63+
</div>
64+
65+
* Click a **loose block or stack** → it runs from there down.
66+
* Click a **reporter** (the oval ones) → the bubble shows its value.
67+
* Click a **hat** → its whole script runs, the same as the green flag.
68+
* Blocks that sit inside a script under a hat are left alone, so nothing runs by
69+
accident while you are building.
70+
71+
Mistakes are explained rather than dumped — the bubble shows
72+
`ZeroDivisionError: division by zero` and the full traceback goes to the console.
73+
Custom blocks, variables and packages all work, because the piece is compiled
74+
with the same imports and definitions as the rest of the tab. Variables start
75+
from their starting values each time, and anything still running after 15
76+
seconds is stopped.
77+
78+
---
79+
5580
## The internet, with nothing installed
5681

5782
The **Web** category is built in. No `pip install requests`, no venv, no
@@ -314,6 +339,7 @@ Until you save, they go next to the application.
314339
| `Ctrl` `I` | Import a Python file |
315340
| `Ctrl` `Z` | Undo |
316341
| `Ctrl` `,` | Settings |
342+
| Click a loose block | Run just that block and see what it printed |
317343
| Drag a block onto the palette | Delete it |
318344
| Right-click the canvas | Clean up, delete all |
319345

docs/screenshot-click.png

91.3 KB
Loading

forever.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
forever - generated by ScratchPy Studio 1.1.0.
2+
forever - generated by ScratchPy Studio 1.2.0.
33
44
This file is written automatically from the blocks in the
55
'forever' tab. Editing it by hand is fine, but the next time

helper.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
helper - generated by ScratchPy Studio 1.1.0.
2+
helper - generated by ScratchPy Studio 1.2.0.
33
44
This file is written automatically from the blocks in the
55
'helper' tab. Editing it by hand is fine, but the next time

runtest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
runtest - generated by ScratchPy Studio 1.1.0.
2+
runtest - generated by ScratchPy Studio 1.2.0.
33
44
This file is written automatically from the blocks in the
55
'runtest' tab. Editing it by hand is fine, but the next time

scratchpy_studio.py

Lines changed: 209 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
from tkinter import font as tkfont
4949

5050
APP_NAME = "ScratchPy Studio"
51-
APP_VERSION = "1.1.0"
51+
APP_VERSION = "1.2.0"
5252
PROJECT_EXT = ".spy"
5353
IS_WINDOWS = sys.platform.startswith("win")
5454

@@ -2913,6 +2913,25 @@ def script_bbox(self, L: Layout, root: Block) -> Tuple[float, float, float, floa
29132913
# SECTION 8 - the workspace: drag, drop, snap, edit
29142914
# =========================================================================== #
29152915

2916+
PIECE_FILE = "_scratchpy_piece.py" # the scratch pad a clicked block runs in
2917+
PIECE_TIMEOUT = 15.0 # seconds before a clicked block is stopped
2918+
2919+
2920+
def rounded_points(x: float, y: float, w: float, h: float,
2921+
r: float) -> List[float]:
2922+
"""A rounded rectangle as a flat list of canvas coordinates."""
2923+
r = max(1.0, min(r, w / 2.0, h / 2.0))
2924+
pts = []
2925+
pts.extend(_arc(x + w - r, y + r, r, -math.pi / 2, 0.0, 6))
2926+
pts.extend(_arc(x + w - r, y + h - r, r, 0.0, math.pi / 2, 6))
2927+
pts.extend(_arc(x + r, y + h - r, r, math.pi / 2, math.pi, 6))
2928+
pts.extend(_arc(x + r, y + r, r, math.pi, math.pi * 1.5, 6))
2929+
flat: List[float] = []
2930+
for px, py in pts:
2931+
flat.extend((px, py))
2932+
return flat
2933+
2934+
29162935
# How near a block has to be before it snaps. Wide and short: being off to the
29172936
# side is fine, being at the wrong height is not.
29182937
SNAP_X = 115.0 # sideways slack for stacking
@@ -2934,6 +2953,7 @@ def __init__(self, master, app: "App"):
29342953
self.editor_win = None
29352954
self.editor_target = None
29362955
self.panning = False
2956+
self.piece_busy = False
29372957

29382958
self.canvas = tk.Canvas(self, bg=UI["canvas_bg"], highlightthickness=0,
29392959
width=600, height=400,
@@ -3004,6 +3024,7 @@ def refresh(self):
30043024
cv.delete("block")
30053025
cv.delete("snaphint")
30063026
cv.delete("watermark")
3027+
cv.delete("bubble")
30073028
self.L.clear()
30083029
if self.file is None:
30093030
return
@@ -3058,6 +3079,7 @@ def block_by_id(self, bid: str) -> Optional[Block]:
30583079
def on_press(self, ev):
30593080
self.canvas.focus_set()
30603081
self.close_editor()
3082+
self.canvas.delete("bubble")
30613083
cx, cy = self.cxy(ev)
30623084
item = self.topmost(cx, cy)
30633085
if item is None:
@@ -3209,6 +3231,7 @@ def finish_drag(self, xr: float, yr: float):
32093231
if d is None:
32103232
return
32113233
b = d["block"]
3234+
clicked = not d.get("moved")
32123235
# work out where it lands before letting go of the drag state, because
32133236
# best_target needs it
32143237
target = self.best_target(b)
@@ -3224,6 +3247,185 @@ def finish_drag(self, xr: float, yr: float):
32243247
self.apply_target(b, target)
32253248
self.refresh()
32263249
self.app.on_change()
3250+
if clicked:
3251+
self.click_block(b)
3252+
3253+
# -- click a loose block to try it -------------------------------------- #
3254+
3255+
def click_block(self, block: Block):
3256+
"""Scratch style: a click on a loose block runs it and shows what
3257+
came out. Blocks that are part of a real script under a hat are left
3258+
alone, so nothing runs by accident while you are building."""
3259+
root = block.top()
3260+
if root.spec.is_hat:
3261+
if block is not root:
3262+
return # part of a program: do not disturb
3263+
piece = block.next # clicking the hat runs its script
3264+
if piece is None:
3265+
return
3266+
else:
3267+
piece = block
3268+
anchor = piece if piece.spec.is_value else piece.last()
3269+
self.run_piece(anchor, piece)
3270+
3271+
def piece_source(self, piece: Block) -> str:
3272+
"""A small standalone program that runs just this block."""
3273+
temp = SpyFile("piece")
3274+
temp.header_imports = list(self.file.header_imports)
3275+
temp.header_code = list(self.file.header_code)
3276+
for script in self.file.scripts:
3277+
if script.spec.shape == "define" or script.spec.id == "event_receive":
3278+
temp.scripts.append(script.copy())
3279+
hat = Block(SPECS["event_start"])
3280+
if piece.spec.is_value:
3281+
show = Block(SPECS["text_print"])
3282+
show.attach_slot("msg", piece.copy())
3283+
hat.attach_next(show)
3284+
else:
3285+
hat.attach_next(piece.copy())
3286+
temp.scripts.append(hat)
3287+
return generate_file(self.app.project, temp)
3288+
3289+
def run_piece(self, anchor: Block, piece: Block):
3290+
if self.piece_busy:
3291+
return
3292+
try:
3293+
source = self.piece_source(piece)
3294+
except Exception as exc:
3295+
self.show_bubble(anchor, "Could not build that: %s" % exc, True)
3296+
return
3297+
problem = check_syntax(source)
3298+
if problem:
3299+
self.show_bubble(anchor, "That does not make sense yet:\n" + problem,
3300+
True)
3301+
return
3302+
folder = self.app.project.folder()
3303+
path = os.path.join(folder, PIECE_FILE)
3304+
try:
3305+
os.makedirs(folder, exist_ok=True)
3306+
with open(path, "w", encoding="utf-8") as fh:
3307+
fh.write(source)
3308+
except Exception as exc:
3309+
self.show_bubble(anchor, "Could not write the test file: %s" % exc,
3310+
True)
3311+
return
3312+
command = run_command(path, self.app.interpreter())
3313+
if not command:
3314+
self.show_bubble(anchor, NO_PYTHON_HINT, True)
3315+
return
3316+
3317+
self.piece_busy = True
3318+
self.show_bubble(anchor, "running...")
3319+
label = describe_block(piece)
3320+
3321+
def worker():
3322+
kw = dict(capture_output=True, text=True, encoding="utf-8",
3323+
errors="replace", cwd=folder, timeout=PIECE_TIMEOUT,
3324+
input="")
3325+
if IS_WINDOWS:
3326+
kw["creationflags"] = NO_WINDOW
3327+
try:
3328+
res = subprocess.run(command, **kw)
3329+
out, err, code = res.stdout, res.stderr, res.returncode
3330+
except subprocess.TimeoutExpired as slow:
3331+
out = (slow.stdout or "")
3332+
if isinstance(out, bytes):
3333+
out = out.decode("utf-8", "replace")
3334+
err, code = "", -9
3335+
except Exception as exc:
3336+
out, err, code = "", str(exc), -1
3337+
self.app.ui(lambda: self.piece_done(anchor, label, out, err, code))
3338+
threading.Thread(target=worker, daemon=True).start()
3339+
3340+
def piece_done(self, anchor: Block, label: str, out: str, err: str,
3341+
code: int):
3342+
self.piece_busy = False
3343+
try:
3344+
os.remove(os.path.join(self.app.project.folder(), PIECE_FILE))
3345+
except Exception:
3346+
pass
3347+
out = (out or "").rstrip()
3348+
err = (err or "").rstrip()
3349+
self.app.console.write("sys", "--- tried: %s ---" % label)
3350+
if out:
3351+
for line in out.split("\n"):
3352+
self.app.console.write("out", line)
3353+
if err:
3354+
for line in err.split("\n"):
3355+
self.app.console.write("err", line)
3356+
if code == -9:
3357+
text = (out + "\n" if out else "") + \
3358+
"(still going after %d seconds, so I stopped it)" % PIECE_TIMEOUT
3359+
self.show_bubble(anchor, text, True)
3360+
return
3361+
if err:
3362+
last = [l for l in err.split("\n") if l.strip()]
3363+
friendly = last[-1] if last else "something went wrong"
3364+
self.show_bubble(anchor, (out + "\n" if out else "") + friendly, True)
3365+
return
3366+
self.show_bubble(anchor, out if out else "(it ran, but printed nothing)")
3367+
3368+
# -- the little report bubble ------------------------------------------- #
3369+
3370+
def wrap_lines(self, text: str, width: int = 52) -> List[str]:
3371+
lines: List[str] = []
3372+
for raw in str(text).split("\n"):
3373+
if not raw:
3374+
lines.append("")
3375+
while len(raw) > width:
3376+
cut = raw.rfind(" ", 0, width)
3377+
if cut < width // 2:
3378+
cut = width
3379+
lines.append(raw[:cut])
3380+
raw = raw[cut:].lstrip()
3381+
if raw:
3382+
lines.append(raw)
3383+
return lines or [""]
3384+
3385+
def show_bubble(self, block: Block, text: str, bad: bool = False):
3386+
"""A little speech bubble under a block, the way Scratch reports."""
3387+
self.canvas.delete("bubble")
3388+
rect = self.L.rect.get(block.id)
3389+
if not rect:
3390+
return
3391+
x, y, w, h = rect
3392+
z = self.renderer.scale
3393+
lines = self.wrap_lines(text)
3394+
clipped = False
3395+
if len(lines) > 9:
3396+
lines, clipped = lines[:9], True
3397+
font = tkfont.Font(family=MONO_FAMILY, size=max(7, int(round(9 * z))))
3398+
line_h = font.metrics("linespace") + 2
3399+
width = max([font.measure(l) for l in lines] + [60]) + 22 * z
3400+
height = line_h * len(lines) + (14 if not clipped else 26) * z
3401+
bx = x + 14 * z
3402+
by = y + h + 11 * z
3403+
edge = "#E38B8B" if bad else "#C9CDD6"
3404+
fill = "#FFF6F6" if bad else "#FFFFFF"
3405+
tag = "bubble"
3406+
self.canvas.create_polygon(
3407+
[bx + 9 * z, by, bx + 27 * z, by, bx + 18 * z, by - 10 * z],
3408+
fill=fill, outline=edge, width=1, tags=tag)
3409+
self.canvas.create_polygon(
3410+
self.renderer.m.pill(bx, by, width, height) if height < 34 * z else
3411+
rounded_points(bx, by, width, height, 10 * z),
3412+
fill=fill, outline=edge, width=1, tags=tag)
3413+
self.canvas.create_polygon(
3414+
[bx + 10 * z, by - 1, bx + 26 * z, by - 1, bx + 18 * z, by - 9 * z],
3415+
fill=fill, outline=fill, tags=tag)
3416+
ty = by + 7 * z
3417+
for line in lines:
3418+
self.canvas.create_text(bx + 11 * z, ty, anchor="nw", text=line,
3419+
font=font,
3420+
fill="#B3261E" if bad else "#3A4356",
3421+
tags=tag)
3422+
ty += line_h
3423+
if clipped:
3424+
self.canvas.create_text(bx + 11 * z, ty, anchor="nw",
3425+
text="... the rest is in the console",
3426+
font=(FONT_FAMILY, max(7, int(8 * z))),
3427+
fill="#8A93A5", tags=tag)
3428+
self.canvas.tag_raise(tag)
32273429

32283430
# -- snapping ----------------------------------------------------------- #
32293431

@@ -6394,7 +6596,12 @@ def help_guide(self):
63946596
" block into it.\n"
63956597
"4. Press the green flag to run. Output appears in the\n"
63966598
" console at the bottom, and the real Python is in the\n"
6397-
" 'Python code' tab.\n"
6599+
" 'Python code' tab.\n\n"
6600+
"Trying one block\n"
6601+
" Click a block that is not joined to a hat and it runs on\n"
6602+
" its own, with a little bubble underneath showing what it\n"
6603+
" printed. Click a hat to run its whole script. Variables\n"
6604+
" start from their starting values each time.\n"
63986605
"5. 'Code folder' at the top opens the folder those .py\n"
63996606
" files are written into.\n\n"
64006607
"Bringing code in\n"

0 commit comments

Comments
 (0)