@edadma/logo 0.2.5 → 0.3.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edadma/logo",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Logo programming language interpreter",
5
5
  "main": "dist/main.js",
6
6
  "module": "dist/main.js",
@@ -0,0 +1,79 @@
1
+ import type { LogoDrawing, TurtleState } from './Logo'
2
+
3
+ /** Animated Logo interpreter with async execution support */
4
+ export declare class LogoAnimated {
5
+ constructor(canvas: HTMLCanvasElement)
6
+
7
+ // === Animation Control ===
8
+
9
+ /** Set the animation speed in milliseconds between yield points. 0 = instant (no animation). */
10
+ setSpeed(ms: number): void
11
+
12
+ /** Get the current animation speed */
13
+ getSpeed(): number
14
+
15
+ /** Check if execution is paused */
16
+ isPaused(): boolean
17
+
18
+ /** Check if execution is running */
19
+ isRunning(): boolean
20
+
21
+ /** Pause execution */
22
+ pause(): void
23
+
24
+ /** Resume execution after pause */
25
+ resume(): void
26
+
27
+ /** Stop execution completely */
28
+ stop(): void
29
+
30
+ // === Execution ===
31
+
32
+ /** Run a Logo program with animation (respects speed setting) */
33
+ run(program: string): void
34
+
35
+ /** Execute a single command synchronously (always instant, for REPL-style use) */
36
+ execute(command: string): string | undefined
37
+
38
+ /** Clear the canvas and reset turtle */
39
+ clear(): void
40
+
41
+ // === Callbacks ===
42
+
43
+ /** Set callback for each animation step (called after each yield point) */
44
+ onStep(callback: () => void): void
45
+
46
+ /** Set callback for completion (receives result or undefined) */
47
+ onComplete(callback: (result: string | undefined) => void): void
48
+
49
+ /** Set callback for errors */
50
+ onError(callback: (error: string) => void): void
51
+
52
+ /** Set output handler for print statements */
53
+ setOutputHandler(handler: (text: string) => void): void
54
+
55
+ /** Clear the output handler */
56
+ clearOutputHandler(): void
57
+
58
+ // === Rendering Settings ===
59
+
60
+ /** Enable or disable path-based rendering (smoother curves) */
61
+ setPathRendering(enabled: boolean): void
62
+
63
+ /** Set the canvas background color */
64
+ setBackgroundColor(color: string): void
65
+
66
+ /** Set the default pen color (for theme-aware drawing) */
67
+ setForegroundColor(color: string): void
68
+
69
+ /** Force a render */
70
+ render(): void
71
+
72
+ // === Variables ===
73
+
74
+ /** Set a global variable */
75
+ setVariable(name: string, value: any): void
76
+
77
+ /** Get a global variable */
78
+ getVariable(name: string): string | undefined
79
+ }
package/src/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export { Logo, type LogoDrawing, type TurtleState } from './Logo'
2
+ export { LogoAnimated } from './LogoAnimated'
@@ -0,0 +1,451 @@
1
+ package io.github.edadma.logo
2
+
3
+ import scala.scalajs.js
4
+ import scala.scalajs.js.annotation.*
5
+ import org.scalajs.dom
6
+ import org.scalajs.dom.html
7
+
8
+ import scala.math.Pi
9
+
10
+ /** Animated Logo interpreter with async execution support for step-by-step animation */
11
+ @JSExportTopLevel("LogoAnimated")
12
+ class LogoAnimated(canvas: html.Canvas) extends js.Object:
13
+ private val ctx = canvas.getContext("2d").asInstanceOf[dom.CanvasRenderingContext2D]
14
+
15
+ // Settings
16
+ private var usePathRendering: Boolean = true
17
+ private var backgroundColor: String = "white"
18
+ private var foregroundColor: (Int, Int, Int) = (0, 0, 0)
19
+ private var isDarkMode: Boolean = false
20
+
21
+ // Animation state
22
+ private var speed: Int = 0 // ms delay between yield points (0 = instant)
23
+ private var paused: Boolean = false
24
+ private var stopped: Boolean = false
25
+ private var running: Boolean = false
26
+ private var pendingTimeout: Int = -1
27
+
28
+ // Current execution state
29
+ private var currentState: EvalResult = Done(LogoNull())
30
+
31
+ // Callbacks
32
+ private var onStepCb: js.UndefOr[js.Function0[Unit]] = js.undefined
33
+ private var onCompleteCb: js.UndefOr[js.Function1[js.UndefOr[String], Unit]] = js.undefined
34
+ private var onErrorCb: js.UndefOr[js.Function1[String, Unit]] = js.undefined
35
+
36
+ // The Logo interpreter
37
+ private val logo = new Logo:
38
+ def event(): Unit =
39
+ if speed > 0 then render()
40
+
41
+ // === Public API ===
42
+
43
+ /** Set the animation speed in milliseconds between yield points. 0 = instant (no animation). */
44
+ def setSpeed(ms: Int): Unit = speed = math.max(0, ms)
45
+
46
+ /** Get the current animation speed */
47
+ def getSpeed(): Int = speed
48
+
49
+ /** Check if execution is paused */
50
+ def isPaused(): Boolean = paused
51
+
52
+ /** Check if execution is running */
53
+ def isRunning(): Boolean = running
54
+
55
+ /** Pause execution */
56
+ def pause(): Unit = paused = true
57
+
58
+ /** Resume execution after pause */
59
+ def resume(): Unit =
60
+ if paused && running then
61
+ paused = false
62
+ continueExecution()
63
+
64
+ /** Stop execution completely */
65
+ def stop(): Unit =
66
+ stopped = true
67
+ paused = false
68
+ running = false
69
+ if pendingTimeout >= 0 then
70
+ dom.window.clearTimeout(pendingTimeout)
71
+ pendingTimeout = -1
72
+
73
+ /** Run a Logo program with animation */
74
+ def run(program: String): Unit =
75
+ stop() // Stop any existing execution
76
+ stopped = false
77
+ paused = false
78
+ running = true
79
+
80
+ if speed == 0 then
81
+ // Instant mode - run synchronously
82
+ try
83
+ val result = logo.interp(program)
84
+ render()
85
+ running = false
86
+ onCompleteCb.foreach { cb =>
87
+ result match
88
+ case LogoUnit => cb(js.undefined)
89
+ case v => cb(v.toString)
90
+ }
91
+ catch
92
+ case e: Throwable =>
93
+ running = false
94
+ onErrorCb.foreach(_(e.getMessage))
95
+ else
96
+ // Animated mode - run asynchronously
97
+ try
98
+ currentState = logo.interpStart(program)
99
+ continueExecution()
100
+ catch
101
+ case e: Throwable =>
102
+ running = false
103
+ onErrorCb.foreach(_(e.getMessage))
104
+
105
+ /** Execute a single command synchronously (always instant, for REPL-style use) */
106
+ def execute(command: String): js.UndefOr[String] =
107
+ try
108
+ val result = logo.interp(command)
109
+ render()
110
+ result match
111
+ case LogoUnit => js.undefined
112
+ case v => v.toString
113
+ catch
114
+ case e: Throwable =>
115
+ onErrorCb.foreach(_(e.getMessage))
116
+ js.undefined
117
+
118
+ /** Clear the screen and reset turtle */
119
+ def clear(): Unit =
120
+ stop()
121
+ logo.clearscreen()
122
+ render()
123
+
124
+ // === Callbacks ===
125
+
126
+ /** Set callback for each animation step (called after each yield point) */
127
+ def onStep(cb: js.Function0[Unit]): Unit = onStepCb = cb
128
+
129
+ /** Set callback for completion (receives result or undefined) */
130
+ def onComplete(cb: js.Function1[js.UndefOr[String], Unit]): Unit = onCompleteCb = cb
131
+
132
+ /** Set callback for errors */
133
+ def onError(cb: js.Function1[String, Unit]): Unit = onErrorCb = cb
134
+
135
+ /** Set output handler for print statements */
136
+ def setOutputHandler(handler: js.Function1[String, Unit]): Unit =
137
+ logo.setOutputHandler(s => handler(s))
138
+
139
+ /** Clear the output handler */
140
+ def clearOutputHandler(): Unit =
141
+ logo.clearOutputHandler()
142
+
143
+ // === Settings ===
144
+
145
+ /** Set whether to use path-based rendering (smoother) or line-based */
146
+ def setPathRendering(enabled: Boolean): Unit =
147
+ usePathRendering = enabled
148
+ render()
149
+
150
+ /** Set the canvas background color */
151
+ def setBackgroundColor(color: String): Unit =
152
+ backgroundColor = color
153
+ val (r, g, b) = parseColor(color)
154
+ val luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
155
+ isDarkMode = luminance < 0.5
156
+ render()
157
+
158
+ /** Set the default pen color (for theme-aware drawing) */
159
+ def setForegroundColor(color: String): Unit =
160
+ val rgb = parseColor(color)
161
+ foregroundColor = rgb
162
+ logo.setDefaultColor(rgb)
163
+ render()
164
+
165
+ /** Set a global variable */
166
+ def setVariable(name: String, value: Any): Unit = logo.setVariable(name, value)
167
+
168
+ /** Get a global variable */
169
+ def getVariable(name: String): js.UndefOr[String] =
170
+ logo.getVariable(name) match
171
+ case Some(v) => v.toString
172
+ case None => js.undefined
173
+
174
+ // === Internal Execution ===
175
+
176
+ private def continueExecution(): Unit =
177
+ if stopped then
178
+ running = false
179
+ return
180
+
181
+ if paused then
182
+ // Check again later
183
+ pendingTimeout = dom.window.setTimeout(() => continueExecution(), 50)
184
+ return
185
+
186
+ try
187
+ // Run steps until yield point (draw/print) or completion
188
+ var steps = 0
189
+ val maxSteps = 5000
190
+
191
+ while steps < maxSteps && !stopped && !paused do
192
+ currentState match
193
+ case Done(v) =>
194
+ running = false
195
+ render()
196
+ onCompleteCb.foreach { cb =>
197
+ v match
198
+ case LogoUnit => cb(js.undefined)
199
+ case LogoNull() => cb(js.undefined)
200
+ case other => cb(other.toString)
201
+ }
202
+ return
203
+ case _ =>
204
+ currentState = logo.trampolineStep(currentState)
205
+ steps += 1
206
+
207
+ // Check if we should yield (drawing OR output command)
208
+ if logo.shouldYield() then
209
+ render()
210
+ onStepCb.foreach(_())
211
+ pendingTimeout = dom.window.setTimeout(() => continueExecution(), speed)
212
+ return
213
+
214
+ // Yield to prevent blocking even without visible output
215
+ if !stopped then
216
+ pendingTimeout = dom.window.setTimeout(() => continueExecution(), 0)
217
+
218
+ catch
219
+ case e: Throwable =>
220
+ running = false
221
+ onErrorCb.foreach(_(e.getMessage))
222
+
223
+ // === Rendering ===
224
+
225
+ /** Force a render */
226
+ def render(): Unit =
227
+ val width = canvas.width
228
+ val height = canvas.height
229
+
230
+ ctx.fillStyle = backgroundColor
231
+ ctx.fillRect(0, 0, width, height)
232
+
233
+ ctx.save()
234
+ ctx.translate(width / 2.0, height / 2.0)
235
+ ctx.scale(1, -1)
236
+
237
+ if usePathRendering then renderWithPaths()
238
+ else renderWithLines()
239
+
240
+ logo.turtle match
241
+ case Some((x, y, heading)) => drawTurtle(x, y, heading)
242
+ case None =>
243
+
244
+ ctx.restore()
245
+
246
+ private def parseColor(color: String): (Int, Int, Int) =
247
+ ctx.fillStyle = color
248
+ val parsed = ctx.fillStyle.asInstanceOf[String]
249
+ if parsed.startsWith("#") then
250
+ val hex = parsed.drop(1)
251
+ if hex.length == 6 then
252
+ val r = Integer.parseInt(hex.substring(0, 2), 16)
253
+ val g = Integer.parseInt(hex.substring(2, 4), 16)
254
+ val b = Integer.parseInt(hex.substring(4, 6), 16)
255
+ (r, g, b)
256
+ else (0, 0, 0)
257
+ else (0, 0, 0)
258
+
259
+ private def renderWithPaths(): Unit =
260
+ case class Style(color: (Int, Int, Int), width: Double)
261
+
262
+ var currentColor: (Int, Int, Int) = foregroundColor
263
+ var currentWidth: Double = 1.0
264
+ var currentStyle: Option[Style] = None
265
+ var pathStarted = false
266
+ var lastX: Double = 0
267
+ var lastY: Double = 0
268
+
269
+ def flushPath(): Unit =
270
+ if pathStarted && currentStyle.isDefined then
271
+ val Style((r, g, b), width) = currentStyle.get
272
+ ctx.strokeStyle = s"rgb($r,$g,$b)"
273
+ ctx.lineWidth = width
274
+ ctx.lineCap = "round"
275
+ ctx.lineJoin = "round"
276
+ ctx.stroke()
277
+ pathStarted = false
278
+ currentStyle = None
279
+
280
+ logo.drawing.foreach {
281
+ case DrawSetColor(colorOpt) =>
282
+ currentColor = colorOpt.getOrElse(foregroundColor)
283
+
284
+ case DrawSetWidth(width) =>
285
+ currentWidth = width
286
+
287
+ case DrawLine(x1, y1, x2, y2) =>
288
+ val style = Style(currentColor, currentWidth)
289
+
290
+ if !currentStyle.contains(style) then
291
+ flushPath()
292
+ currentStyle = Some(style)
293
+ ctx.beginPath()
294
+ ctx.moveTo(x1, y1)
295
+ pathStarted = true
296
+ lastX = x1
297
+ lastY = y1
298
+
299
+ if !pathStarted || lastX != x1 || lastY != y1 then
300
+ if !pathStarted then
301
+ ctx.beginPath()
302
+ pathStarted = true
303
+ ctx.moveTo(x1, y1)
304
+
305
+ ctx.lineTo(x2, y2)
306
+ lastX = x2
307
+ lastY = y2
308
+
309
+ case DrawArc(x, y, heading, angleDeg, radius) =>
310
+ flushPath()
311
+ renderArc(x, y, heading, angleDeg, radius, currentColor, currentWidth)
312
+
313
+ case DrawLabel(x, y, heading, text) =>
314
+ flushPath()
315
+ renderLabel(x, y, heading, text)
316
+ }
317
+
318
+ flushPath()
319
+
320
+ private def renderWithLines(): Unit =
321
+ var currentColor: (Int, Int, Int) = foregroundColor
322
+ var currentWidth: Double = 1.0
323
+
324
+ logo.drawing.foreach {
325
+ case DrawSetColor(colorOpt) =>
326
+ currentColor = colorOpt.getOrElse(foregroundColor)
327
+
328
+ case DrawSetWidth(width) =>
329
+ currentWidth = width
330
+
331
+ case DrawLine(x1, y1, x2, y2) =>
332
+ val (r, g, b) = currentColor
333
+ ctx.strokeStyle = s"rgb($r,$g,$b)"
334
+ ctx.lineWidth = currentWidth
335
+ ctx.beginPath()
336
+ ctx.moveTo(x1, y1)
337
+ ctx.lineTo(x2, y2)
338
+ ctx.stroke()
339
+
340
+ case DrawArc(x, y, heading, angleDeg, radius) =>
341
+ renderArc(x, y, heading, angleDeg, radius, currentColor, currentWidth)
342
+
343
+ case DrawLabel(x, y, heading, text) =>
344
+ renderLabel(x, y, heading, text)
345
+ }
346
+
347
+ private def renderLabel(x: Double, y: Double, heading: Double, text: String): Unit =
348
+ ctx.save()
349
+ ctx.translate(x, y)
350
+ ctx.rotate(heading)
351
+ ctx.scale(1, -1)
352
+ ctx.fillStyle = "black"
353
+ ctx.font = "20px sans-serif"
354
+ ctx.fillText(text, 0, 0)
355
+ ctx.restore()
356
+
357
+ private def renderArc(x: Double, y: Double, heading: Double, angleDeg: Double, radius: Double, color: (Int, Int, Int), width: Double): Unit =
358
+ val (r, g, b) = color
359
+ val sign = if angleDeg >= 0 then 1.0 else -1.0
360
+ val absAngle = math.abs(angleDeg)
361
+
362
+ val perpAngle = heading + sign * Pi / 2
363
+ val cx = x + radius * math.cos(perpAngle)
364
+ val cy = y + radius * math.sin(perpAngle)
365
+
366
+ val startAngle = math.atan2(y - cy, x - cx)
367
+
368
+ val sweepRad = math.toRadians(absAngle)
369
+ val endAngle = if angleDeg >= 0 then startAngle - sweepRad else startAngle + sweepRad
370
+
371
+ ctx.strokeStyle = s"rgb($r,$g,$b)"
372
+ ctx.lineWidth = width
373
+ ctx.lineCap = "round"
374
+ ctx.beginPath()
375
+ ctx.arc(cx, cy, radius, startAngle, endAngle, angleDeg >= 0)
376
+ ctx.stroke()
377
+
378
+ private def drawTurtle(x: Double, y: Double, heading: Double): Unit =
379
+ ctx.save()
380
+ ctx.translate(x, y)
381
+ ctx.rotate(heading + Pi / 2)
382
+
383
+ val (shellFill, shellOutline, shellPattern, skinColor, skinOutline) =
384
+ if isDarkMode then
385
+ ("#2d5a27", "#1a3a18", "#3d7a37", "#4a7a44", "#1a3a18")
386
+ else
387
+ ("#5a9a50", "#3a6a38", "#7aba70", "#7ab070", "#3a6a38")
388
+
389
+ // Tail
390
+ ctx.beginPath()
391
+ ctx.moveTo(0, 10)
392
+ ctx.lineTo(0, 14)
393
+ ctx.strokeStyle = skinColor
394
+ ctx.lineWidth = 2
395
+ ctx.lineCap = "round"
396
+ ctx.stroke()
397
+
398
+ // Legs
399
+ ctx.fillStyle = skinColor
400
+ ctx.strokeStyle = skinOutline
401
+ ctx.lineWidth = 1
402
+ ctx.beginPath()
403
+ ctx.ellipse(-7, -6, 3, 5, 0.4, 0, 2 * Pi)
404
+ ctx.fill()
405
+ ctx.stroke()
406
+ ctx.beginPath()
407
+ ctx.ellipse(7, -6, 3, 5, -0.4, 0, 2 * Pi)
408
+ ctx.fill()
409
+ ctx.stroke()
410
+ ctx.beginPath()
411
+ ctx.ellipse(-6, 6, 3, 4, 0.3, 0, 2 * Pi)
412
+ ctx.fill()
413
+ ctx.stroke()
414
+ ctx.beginPath()
415
+ ctx.ellipse(6, 6, 3, 4, -0.3, 0, 2 * Pi)
416
+ ctx.fill()
417
+ ctx.stroke()
418
+
419
+ // Head
420
+ ctx.beginPath()
421
+ ctx.ellipse(0, -14, 4, 5, 0, 0, 2 * Pi)
422
+ ctx.fillStyle = skinColor
423
+ ctx.fill()
424
+ ctx.strokeStyle = skinOutline
425
+ ctx.lineWidth = 1.5
426
+ ctx.stroke()
427
+
428
+ // Eyes
429
+ ctx.fillStyle = "black"
430
+ ctx.beginPath()
431
+ ctx.arc(-1.5, -15, 1, 0, 2 * Pi)
432
+ ctx.arc(1.5, -15, 1, 0, 2 * Pi)
433
+ ctx.fill()
434
+
435
+ // Shell
436
+ ctx.beginPath()
437
+ ctx.ellipse(0, 0, 8, 10, 0, 0, 2 * Pi)
438
+ ctx.fillStyle = shellFill
439
+ ctx.fill()
440
+ ctx.strokeStyle = shellOutline
441
+ ctx.lineWidth = 1.5
442
+ ctx.stroke()
443
+
444
+ // Shell pattern
445
+ ctx.strokeStyle = shellPattern
446
+ ctx.lineWidth = 1
447
+ ctx.beginPath()
448
+ ctx.ellipse(0, 0, 5, 6, 0, 0, 2 * Pi)
449
+ ctx.stroke()
450
+
451
+ ctx.restore()
@@ -58,6 +58,7 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
58
58
  private var initialized: Boolean = false
59
59
  private var backgroundColor: String = "white"
60
60
  private var foregroundColor: (Int, Int, Int) = (0, 0, 0) // RGB for theme-aware drawing
61
+ private var isDarkMode: Boolean = false
61
62
  private var eventHandler: Option[js.Function0[Unit]] = None
62
63
 
63
64
  private val logo = new Logo:
@@ -99,6 +100,10 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
99
100
  /** Set the canvas background color */
100
101
  def setBackgroundColor(color: String): Unit =
101
102
  backgroundColor = color
103
+ val (r, g, b) = parseColor(color)
104
+ // Calculate luminance to determine if dark mode
105
+ val luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
106
+ isDarkMode = luminance < 0.5
102
107
  render()
103
108
 
104
109
  /** Set the default pen color (used after clear and for theme-aware drawing) */
@@ -348,18 +353,25 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
348
353
  ctx.translate(x, y)
349
354
  ctx.rotate(heading + Pi / 2)
350
355
 
356
+ // Theme-aware colors
357
+ val (shellFill, shellOutline, shellPattern, skinColor, skinOutline) =
358
+ if isDarkMode then
359
+ ("#2d5a27", "#1a3a18", "#3d7a37", "#4a7a44", "#1a3a18")
360
+ else
361
+ ("#5a9a50", "#3a6a38", "#7aba70", "#7ab070", "#3a6a38")
362
+
351
363
  // Tail (behind shell)
352
364
  ctx.beginPath()
353
365
  ctx.moveTo(0, 10)
354
366
  ctx.lineTo(0, 14)
355
- ctx.strokeStyle = "#4a7a44"
367
+ ctx.strokeStyle = skinColor
356
368
  ctx.lineWidth = 2
357
369
  ctx.lineCap = "round"
358
370
  ctx.stroke()
359
371
 
360
372
  // Legs (behind shell)
361
- ctx.fillStyle = "#4a7a44"
362
- ctx.strokeStyle = "#1a3a18"
373
+ ctx.fillStyle = skinColor
374
+ ctx.strokeStyle = skinOutline
363
375
  ctx.lineWidth = 1
364
376
  // Front legs
365
377
  ctx.beginPath()
@@ -383,9 +395,9 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
383
395
  // Head (behind shell)
384
396
  ctx.beginPath()
385
397
  ctx.ellipse(0, -14, 4, 5, 0, 0, 2 * Pi)
386
- ctx.fillStyle = "#4a7a44"
398
+ ctx.fillStyle = skinColor
387
399
  ctx.fill()
388
- ctx.strokeStyle = "#1a3a18"
400
+ ctx.strokeStyle = skinOutline
389
401
  ctx.lineWidth = 1.5
390
402
  ctx.stroke()
391
403
 
@@ -399,14 +411,14 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
399
411
  // Shell (on top)
400
412
  ctx.beginPath()
401
413
  ctx.ellipse(0, 0, 8, 10, 0, 0, 2 * Pi)
402
- ctx.fillStyle = "#2d5a27"
414
+ ctx.fillStyle = shellFill
403
415
  ctx.fill()
404
- ctx.strokeStyle = "#1a3a18"
416
+ ctx.strokeStyle = shellOutline
405
417
  ctx.lineWidth = 1.5
406
418
  ctx.stroke()
407
419
 
408
420
  // Shell pattern
409
- ctx.strokeStyle = "#3d7a37"
421
+ ctx.strokeStyle = shellPattern
410
422
  ctx.lineWidth = 1
411
423
  ctx.beginPath()
412
424
  ctx.ellipse(0, 0, 5, 6, 0, 0, 2 * Pi)