@tanagram/cli 0.4.14 → 0.4.18

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/tui/puzzle.go DELETED
@@ -1,694 +0,0 @@
1
- package tui
2
-
3
- import (
4
- "encoding/json"
5
- "fmt"
6
- "math"
7
- "os"
8
- "strings"
9
-
10
- tea "github.com/charmbracelet/bubbletea"
11
- "github.com/charmbracelet/lipgloss"
12
- )
13
-
14
- // PieceType represents the type of tangram piece
15
- type PieceType string
16
-
17
- const (
18
- LargeTriangle PieceType = "large-triangle"
19
- MediumTriangle PieceType = "medium-triangle"
20
- SmallTriangle PieceType = "small-triangle"
21
- Square PieceType = "square"
22
- Parallelogram PieceType = "parallelogram"
23
- )
24
-
25
- // Position represents x,y coordinates
26
- type Position struct {
27
- X int
28
- Y int
29
- }
30
-
31
- // TangramPiece represents a single tangram piece
32
- type TangramPiece struct {
33
- ID int
34
- Type PieceType
35
- Color lipgloss.Color
36
- Position Position
37
- Rotation int // 0, 90, 180, 270
38
- Flipped bool
39
- }
40
-
41
- // PuzzleModel is the Bubble Tea model for the puzzle editor
42
- type PuzzleModel struct {
43
- pieces []TangramPiece
44
- selectedIndex int
45
- canvasWidth int
46
- canvasHeight int
47
- width int
48
- height int
49
- }
50
-
51
- // NewPuzzleModel creates a new puzzle editor model
52
- func NewPuzzleModel() PuzzleModel {
53
- // Define the 7 tangram pieces with initial scattered positions
54
- pieces := []TangramPiece{
55
- {ID: 1, Type: SmallTriangle, Color: "#BA68C8", Position: Position{2, 1}, Rotation: 0, Flipped: false},
56
- {ID: 2, Type: SmallTriangle, Color: "#FFB74D", Position: Position{20, 1}, Rotation: 0, Flipped: false},
57
- {ID: 3, Type: MediumTriangle, Color: "#42A5F5", Position: Position{38, 1}, Rotation: 0, Flipped: false},
58
- {ID: 4, Type: LargeTriangle, Color: "#EF4444", Position: Position{2, 10}, Rotation: 0, Flipped: false},
59
- {ID: 5, Type: LargeTriangle, Color: "#FF9800", Position: Position{32, 10}, Rotation: 0, Flipped: false},
60
- {ID: 6, Type: Square, Color: "#7E57C2", Position: Position{62, 1}, Rotation: 0, Flipped: false},
61
- {ID: 7, Type: Parallelogram, Color: "#66BB6A", Position: Position{62, 10}, Rotation: 0, Flipped: false},
62
- }
63
-
64
- return PuzzleModel{
65
- pieces: pieces,
66
- selectedIndex: 0,
67
- canvasWidth: 120,
68
- canvasHeight: 45,
69
- }
70
- }
71
-
72
- // Init initializes the model
73
- func (m PuzzleModel) Init() tea.Cmd {
74
- return nil
75
- }
76
-
77
- // Update handles messages
78
- func (m PuzzleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
79
- switch msg := msg.(type) {
80
- case tea.KeyMsg:
81
- switch msg.String() {
82
- case "ctrl+c", "q", "esc":
83
- return m, tea.Quit
84
- case "left", "h":
85
- // Select previous piece
86
- m.selectedIndex = (m.selectedIndex - 1 + len(m.pieces)) % len(m.pieces)
87
- case "right", "l":
88
- // Select next piece
89
- m.selectedIndex = (m.selectedIndex + 1) % len(m.pieces)
90
- case "up", "k":
91
- // Move selected piece up
92
- if m.pieces[m.selectedIndex].Position.Y > 0 {
93
- m.pieces[m.selectedIndex].Position.Y--
94
- }
95
- case "down", "j":
96
- // Move selected piece down
97
- if m.pieces[m.selectedIndex].Position.Y < m.canvasHeight-15 {
98
- m.pieces[m.selectedIndex].Position.Y++
99
- }
100
- case "shift+left", "H":
101
- // Move selected piece left
102
- if m.pieces[m.selectedIndex].Position.X > 0 {
103
- m.pieces[m.selectedIndex].Position.X--
104
- }
105
- case "shift+right", "L":
106
- // Move selected piece right
107
- if m.pieces[m.selectedIndex].Position.X < m.canvasWidth-35 {
108
- m.pieces[m.selectedIndex].Position.X++
109
- }
110
- case "r":
111
- // Rotate 90° clockwise
112
- m.pieces[m.selectedIndex].Rotation = (m.pieces[m.selectedIndex].Rotation + 90) % 360
113
- case "R":
114
- // Rotate 90° counter-clockwise
115
- m.pieces[m.selectedIndex].Rotation = (m.pieces[m.selectedIndex].Rotation - 90 + 360) % 360
116
- case "[":
117
- // Rotate 45° counter-clockwise (fine rotation)
118
- m.pieces[m.selectedIndex].Rotation = (m.pieces[m.selectedIndex].Rotation - 45 + 360) % 360
119
- case "]":
120
- // Rotate 45° clockwise (fine rotation)
121
- m.pieces[m.selectedIndex].Rotation = (m.pieces[m.selectedIndex].Rotation + 45) % 360
122
- case "f":
123
- // Flip piece
124
- m.pieces[m.selectedIndex].Flipped = !m.pieces[m.selectedIndex].Flipped
125
- case "e":
126
- // Export configuration to console
127
- m.exportConfig()
128
- case "s":
129
- // Save configuration to file
130
- m.saveConfig()
131
- case "o":
132
- // Open/load configuration from file
133
- m.loadConfig()
134
- }
135
- case tea.WindowSizeMsg:
136
- m.width = msg.Width
137
- m.height = msg.Height
138
- }
139
- return m, nil
140
- }
141
-
142
- // View renders the UI
143
- func (m PuzzleModel) View() string {
144
- // Create canvas with piece IDs (0 = empty, 1-7 = piece IDs)
145
- canvas := make([][]int, m.canvasHeight)
146
- for i := range canvas {
147
- canvas[i] = make([]int, m.canvasWidth)
148
- }
149
-
150
- // Render pieces (in order, so later pieces appear on top)
151
- for idx, piece := range m.pieces {
152
- m.renderPieceToCanvas(&canvas, piece, idx)
153
- }
154
-
155
- // Convert canvas to styled string with smooth edges
156
- var lines []string
157
- for y, row := range canvas {
158
- var line strings.Builder
159
- for x := range row {
160
- char, color := m.getCellDisplay(canvas, x, y)
161
- if color != "" {
162
- style := lipgloss.NewStyle().Foreground(color)
163
- line.WriteString(style.Render(string(char)))
164
- } else {
165
- line.WriteRune(char)
166
- }
167
- }
168
- lines = append(lines, line.String())
169
- }
170
-
171
- canvasOutput := strings.Join(lines, "\n")
172
-
173
- // Border for canvas
174
- canvasBorder := lipgloss.NewStyle().
175
- Border(lipgloss.RoundedBorder()).
176
- BorderForeground(lipgloss.Color("#8B5CF6")).
177
- Padding(0, 1)
178
-
179
- borderedCanvas := canvasBorder.Render(canvasOutput)
180
-
181
- // Title
182
- titleStyle := lipgloss.NewStyle().
183
- Bold(true).
184
- Foreground(lipgloss.Color("#FFFFFF")).
185
- Background(lipgloss.Color("#8B5CF6")).
186
- Padding(0, 2)
187
-
188
- title := titleStyle.Render(" TANAGRAM PUZZLE EDITOR ")
189
-
190
- // Selected piece info
191
- selectedPiece := m.pieces[m.selectedIndex]
192
- infoStyle := lipgloss.NewStyle().
193
- Foreground(lipgloss.Color("#AAAAAA"))
194
-
195
- info := infoStyle.Render(fmt.Sprintf(
196
- " Piece #%d (%s) @ (%d,%d) • %d° • Flipped:%t",
197
- selectedPiece.ID,
198
- m.shortType(selectedPiece.Type),
199
- selectedPiece.Position.X,
200
- selectedPiece.Position.Y,
201
- selectedPiece.Rotation,
202
- selectedPiece.Flipped,
203
- ))
204
-
205
- // Piece legend - compact version
206
- var legendParts []string
207
- for idx, piece := range m.pieces {
208
- pieceStyle := lipgloss.NewStyle().Foreground(piece.Color).Bold(true)
209
- marker := " "
210
- if idx == m.selectedIndex {
211
- marker = "▶"
212
- }
213
- legendParts = append(legendParts, fmt.Sprintf("%s%s%d",
214
- marker,
215
- pieceStyle.Render("█"),
216
- piece.ID,
217
- ))
218
- }
219
- legendStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
220
- legend := legendStyle.Render(" Pieces: " + strings.Join(legendParts, " "))
221
-
222
- // Controls help - more compact
223
- helpStyle := lipgloss.NewStyle().
224
- Foreground(lipgloss.Color("#666666")).
225
- Italic(true)
226
-
227
- help := helpStyle.Render(
228
- " ←→/hl:select ↑↓/kj:move H/L:move left/right r/R:90° [/]:45° f:flip s:save o:load e:export q:quit",
229
- )
230
-
231
- // Combine all parts with consistent spacing
232
- return lipgloss.JoinVertical(lipgloss.Left,
233
- "",
234
- title,
235
- info,
236
- "",
237
- borderedCanvas,
238
- "",
239
- legend,
240
- help,
241
- "",
242
- )
243
- }
244
-
245
- // shortType returns abbreviated type name
246
- func (m PuzzleModel) shortType(t PieceType) string {
247
- switch t {
248
- case LargeTriangle:
249
- return "Lg△"
250
- case MediumTriangle:
251
- return "Md△"
252
- case SmallTriangle:
253
- return "Sm△"
254
- case Square:
255
- return "□"
256
- case Parallelogram:
257
- return "▱"
258
- default:
259
- return string(t)
260
- }
261
- }
262
-
263
- // renderPieceToCanvas renders a piece to the canvas by piece ID
264
- func (m PuzzleModel) renderPieceToCanvas(canvas *[][]int, piece TangramPiece, pieceIndex int) {
265
- // Get piece shape based on type and rotation
266
- points := m.getPiecePoints(piece)
267
-
268
- // Fill the piece area with piece index + 1 (so 0 remains empty)
269
- for _, point := range points {
270
- x := piece.Position.X + point.X
271
- y := piece.Position.Y + point.Y
272
-
273
- if y >= 0 && y < len(*canvas) && x >= 0 && x < len((*canvas)[0]) {
274
- (*canvas)[y][x] = pieceIndex + 1
275
- }
276
- }
277
- }
278
-
279
- // getCellDisplay returns the character and color for a cell
280
- func (m PuzzleModel) getCellDisplay(canvas [][]int, x, y int) (rune, lipgloss.Color) {
281
- pieceID := canvas[y][x]
282
-
283
- // Empty cell - subtle grid pattern
284
- if pieceID == 0 {
285
- // Show very faint grid lines
286
- if x%5 == 0 || y%5 == 0 {
287
- return '·', lipgloss.Color("#444444")
288
- }
289
- return ' ', ""
290
- }
291
-
292
- pieceIndex := pieceID - 1
293
- piece := m.pieces[pieceIndex]
294
- isSelected := pieceIndex == m.selectedIndex
295
-
296
- // Check if this is an edge cell
297
- isEdge := m.isEdgeCell(canvas, x, y, pieceID)
298
-
299
- // Use different characters for edges and fills to create depth
300
- var char rune
301
- if isSelected {
302
- // Selected piece has lighter fill to show it's active
303
- if isEdge {
304
- char = '█' // Solid edge
305
- } else {
306
- char = '▓' // Lighter fill
307
- }
308
- } else {
309
- // Normal pieces are all solid
310
- char = '█'
311
- }
312
-
313
- return char, piece.Color
314
- }
315
-
316
- // isEdgeCell checks if a cell is on the edge of its piece
317
- func (m PuzzleModel) isEdgeCell(canvas [][]int, x, y int, pieceID int) bool {
318
- // Check all 4 directions
319
- directions := []struct{ dx, dy int }{
320
- {-1, 0}, {1, 0}, {0, -1}, {0, 1},
321
- }
322
-
323
- for _, dir := range directions {
324
- nx, ny := x+dir.dx, y+dir.dy
325
-
326
- // Out of bounds = edge
327
- if ny < 0 || ny >= len(canvas) || nx < 0 || nx >= len(canvas[0]) {
328
- return true
329
- }
330
-
331
- // Different piece or empty = edge
332
- if canvas[ny][nx] != pieceID {
333
- return true
334
- }
335
- }
336
-
337
- return false
338
- }
339
-
340
- // getPiecePoints returns the relative points for a piece based on its type and rotation
341
- func (m PuzzleModel) getPiecePoints(piece TangramPiece) []Position {
342
- var basePoints []Position
343
-
344
- // Generate pieces in "true" square proportions (before aspect ratio correction)
345
- switch piece.Type {
346
- case SmallTriangle:
347
- // Small right triangle - 8x8 in true proportions
348
- height := 8
349
- for y := 0; y < height; y++ {
350
- // Right triangle: width decreases by 1 each row
351
- for x := 0; x <= height-1-y; x++ {
352
- basePoints = append(basePoints, Position{x, y})
353
- }
354
- }
355
- case MediumTriangle:
356
- // Medium right triangle - 10x10 in true proportions
357
- height := 10
358
- for y := 0; y < height; y++ {
359
- for x := 0; x <= height-1-y; x++ {
360
- basePoints = append(basePoints, Position{x, y})
361
- }
362
- }
363
- case LargeTriangle:
364
- // Large right triangle - 14x14 in true proportions
365
- height := 14
366
- for y := 0; y < height; y++ {
367
- for x := 0; x <= height-1-y; x++ {
368
- basePoints = append(basePoints, Position{x, y})
369
- }
370
- }
371
- case Square:
372
- // Square - 9x9 in true proportions (between small and medium triangle)
373
- size := 9
374
- for y := 0; y < size; y++ {
375
- for x := 0; x < size; x++ {
376
- basePoints = append(basePoints, Position{x, y})
377
- }
378
- }
379
- case Parallelogram:
380
- // Parallelogram in true proportions
381
- height := 5
382
- baseLength := 11
383
- for y := 0; y < height; y++ {
384
- // Slant offset decreases as we go down
385
- offset := height - 1 - y
386
- for x := 0; x < baseLength; x++ {
387
- basePoints = append(basePoints, Position{x + offset, y})
388
- }
389
- }
390
- }
391
-
392
- // Apply rotation FIRST (on true proportions)
393
- rotatedPoints := m.rotatePoints(basePoints, piece.Rotation)
394
-
395
- // Fill any gaps in the rotated shape
396
- rotatedPoints = m.fillShape(rotatedPoints)
397
-
398
- // Apply flip
399
- if piece.Flipped {
400
- rotatedPoints = m.flipPoints(rotatedPoints)
401
- }
402
-
403
- // THEN apply aspect ratio correction (stretch horizontally by 2x)
404
- aspectCorrectedPoints := make([]Position, 0, len(rotatedPoints)*2)
405
- for _, p := range rotatedPoints {
406
- // Each point becomes 2 points horizontally
407
- aspectCorrectedPoints = append(aspectCorrectedPoints, Position{p.X * 2, p.Y})
408
- aspectCorrectedPoints = append(aspectCorrectedPoints, Position{p.X*2 + 1, p.Y})
409
- }
410
-
411
- return aspectCorrectedPoints
412
- }
413
-
414
- // fillShape fills in any gaps in a rotated shape using scan-line fill
415
- func (m PuzzleModel) fillShape(points []Position) []Position {
416
- if len(points) == 0 {
417
- return points
418
- }
419
-
420
- // Create a map of existing points for fast lookup
421
- pointMap := make(map[Position]bool)
422
- for _, p := range points {
423
- pointMap[p] = true
424
- }
425
-
426
- // Find bounding box
427
- minX, maxX := points[0].X, points[0].X
428
- minY, maxY := points[0].Y, points[0].Y
429
- for _, p := range points {
430
- if p.X < minX {
431
- minX = p.X
432
- }
433
- if p.X > maxX {
434
- maxX = p.X
435
- }
436
- if p.Y < minY {
437
- minY = p.Y
438
- }
439
- if p.Y > maxY {
440
- maxY = p.Y
441
- }
442
- }
443
-
444
- // Fill horizontally (row by row)
445
- filledMap := make(map[Position]bool)
446
- for y := minY; y <= maxY; y++ {
447
- var xCoords []int
448
- for _, p := range points {
449
- if p.Y == y {
450
- xCoords = append(xCoords, p.X)
451
- }
452
- }
453
-
454
- if len(xCoords) == 0 {
455
- continue
456
- }
457
-
458
- rowMinX, rowMaxX := xCoords[0], xCoords[0]
459
- for _, x := range xCoords {
460
- if x < rowMinX {
461
- rowMinX = x
462
- }
463
- if x > rowMaxX {
464
- rowMaxX = x
465
- }
466
- }
467
-
468
- for x := rowMinX; x <= rowMaxX; x++ {
469
- filledMap[Position{x, y}] = true
470
- }
471
- }
472
-
473
- // Fill vertically (column by column) to catch any remaining gaps
474
- for x := minX; x <= maxX; x++ {
475
- var yCoords []int
476
- for p := range filledMap {
477
- if p.X == x {
478
- yCoords = append(yCoords, p.Y)
479
- }
480
- }
481
-
482
- if len(yCoords) == 0 {
483
- continue
484
- }
485
-
486
- colMinY, colMaxY := yCoords[0], yCoords[0]
487
- for _, y := range yCoords {
488
- if y < colMinY {
489
- colMinY = y
490
- }
491
- if y > colMaxY {
492
- colMaxY = y
493
- }
494
- }
495
-
496
- for y := colMinY; y <= colMaxY; y++ {
497
- filledMap[Position{x, y}] = true
498
- }
499
- }
500
-
501
- // Convert map back to slice
502
- filledPoints := make([]Position, 0, len(filledMap))
503
- for p := range filledMap {
504
- filledPoints = append(filledPoints, p)
505
- }
506
-
507
- return filledPoints
508
- }
509
-
510
- // rotatePoints rotates points by the given angle (0, 90, 180, 270)
511
- func (m PuzzleModel) rotatePoints(points []Position, angle int) []Position {
512
- if angle == 0 {
513
- return points
514
- }
515
-
516
- var rotated []Position
517
- rad := float64(angle) * math.Pi / 180.0
518
- cos := math.Cos(rad)
519
- sin := math.Sin(rad)
520
-
521
- // Find center of points
522
- var sumX, sumY int
523
- for _, p := range points {
524
- sumX += p.X
525
- sumY += p.Y
526
- }
527
- centerX := float64(sumX) / float64(len(points))
528
- centerY := float64(sumY) / float64(len(points))
529
-
530
- for _, p := range points {
531
- // Translate to origin
532
- x := float64(p.X) - centerX
533
- y := float64(p.Y) - centerY
534
-
535
- // Rotate
536
- newX := x*cos - y*sin
537
- newY := x*sin + y*cos
538
-
539
- // Translate back
540
- rotated = append(rotated, Position{
541
- X: int(math.Round(newX + centerX)),
542
- Y: int(math.Round(newY + centerY)),
543
- })
544
- }
545
-
546
- // Normalize to positive coordinates
547
- minX, minY := rotated[0].X, rotated[0].Y
548
- for _, p := range rotated {
549
- if p.X < minX {
550
- minX = p.X
551
- }
552
- if p.Y < minY {
553
- minY = p.Y
554
- }
555
- }
556
-
557
- for i := range rotated {
558
- rotated[i].X -= minX
559
- rotated[i].Y -= minY
560
- }
561
-
562
- return rotated
563
- }
564
-
565
- // flipPoints flips points horizontally
566
- func (m PuzzleModel) flipPoints(points []Position) []Position {
567
- if len(points) == 0 {
568
- return points
569
- }
570
-
571
- // Find max X
572
- maxX := points[0].X
573
- for _, p := range points {
574
- if p.X > maxX {
575
- maxX = p.X
576
- }
577
- }
578
-
579
- // Flip horizontally
580
- var flipped []Position
581
- for _, p := range points {
582
- flipped = append(flipped, Position{
583
- X: maxX - p.X,
584
- Y: p.Y,
585
- })
586
- }
587
-
588
- return flipped
589
- }
590
-
591
- // exportConfig prints the current configuration
592
- func (m PuzzleModel) exportConfig() {
593
- fmt.Println("\n=== TANGRAM CONFIGURATION ===")
594
- for _, piece := range m.pieces {
595
- fmt.Printf("Piece #%d (%s): Position(%d, %d), Rotation: %d°, Flipped: %t\n",
596
- piece.ID,
597
- piece.Type,
598
- piece.Position.X,
599
- piece.Position.Y,
600
- piece.Rotation,
601
- piece.Flipped,
602
- )
603
- }
604
- fmt.Println("============================\n")
605
- }
606
-
607
- // saveConfig saves the current configuration to a JSON file
608
- func (m *PuzzleModel) saveConfig() {
609
- // Create a simplified structure for saving
610
- type SavedPiece struct {
611
- ID int `json:"id"`
612
- Type string `json:"type"`
613
- Position Position `json:"position"`
614
- Rotation int `json:"rotation"`
615
- Flipped bool `json:"flipped"`
616
- }
617
-
618
- var savedPieces []SavedPiece
619
- for _, piece := range m.pieces {
620
- savedPieces = append(savedPieces, SavedPiece{
621
- ID: piece.ID,
622
- Type: string(piece.Type),
623
- Position: piece.Position,
624
- Rotation: piece.Rotation,
625
- Flipped: piece.Flipped,
626
- })
627
- }
628
-
629
- data, err := json.MarshalIndent(savedPieces, "", " ")
630
- if err != nil {
631
- fmt.Printf("\n❌ Error saving configuration: %v\n", err)
632
- return
633
- }
634
-
635
- filename := "tanagram-config.json"
636
- err = os.WriteFile(filename, data, 0644)
637
- if err != nil {
638
- fmt.Printf("\n❌ Error writing file: %v\n", err)
639
- return
640
- }
641
-
642
- fmt.Printf("\n✓ Configuration saved to %s\n", filename)
643
- }
644
-
645
- // loadConfig loads a configuration from a JSON file
646
- func (m *PuzzleModel) loadConfig() {
647
- filename := "tanagram-config.json"
648
-
649
- data, err := os.ReadFile(filename)
650
- if err != nil {
651
- fmt.Printf("\n❌ Error reading file: %v\n", err)
652
- fmt.Println(" Make sure tanagram-config.json exists in the current directory")
653
- return
654
- }
655
-
656
- type SavedPiece struct {
657
- ID int `json:"id"`
658
- Type string `json:"type"`
659
- Position Position `json:"position"`
660
- Rotation int `json:"rotation"`
661
- Flipped bool `json:"flipped"`
662
- }
663
-
664
- var savedPieces []SavedPiece
665
- err = json.Unmarshal(data, &savedPieces)
666
- if err != nil {
667
- fmt.Printf("\n❌ Error parsing configuration: %v\n", err)
668
- return
669
- }
670
-
671
- // Update piece positions, rotations, and flips
672
- for i, saved := range savedPieces {
673
- if i < len(m.pieces) && saved.ID == m.pieces[i].ID {
674
- m.pieces[i].Position = saved.Position
675
- m.pieces[i].Rotation = saved.Rotation
676
- m.pieces[i].Flipped = saved.Flipped
677
- }
678
- }
679
-
680
- fmt.Printf("\n✓ Configuration loaded from %s\n", filename)
681
- }
682
-
683
- // RunPuzzleEditor displays the puzzle editor
684
- func RunPuzzleEditor() error {
685
- model := NewPuzzleModel()
686
- p := tea.NewProgram(model, tea.WithAltScreen())
687
-
688
- _, err := p.Run()
689
- if err != nil {
690
- return fmt.Errorf("error running puzzle editor: %w", err)
691
- }
692
-
693
- return nil
694
- }