@tanagram/cli 0.1.25 → 0.1.26

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.
@@ -0,0 +1,359 @@
1
+ package tui
2
+
3
+ import (
4
+ "encoding/json"
5
+ "math"
6
+ "os"
7
+
8
+ "github.com/charmbracelet/lipgloss"
9
+ )
10
+
11
+ // TanagramRenderer handles rendering tangram pieces to an ASCII canvas
12
+ type TanagramRenderer struct {
13
+ width int
14
+ height int
15
+ }
16
+
17
+ // NewTanagramRenderer creates a new renderer with the given dimensions
18
+ func NewTanagramRenderer(width, height int) *TanagramRenderer {
19
+ return &TanagramRenderer{
20
+ width: width,
21
+ height: height,
22
+ }
23
+ }
24
+
25
+ // RenderPiecesToCanvas renders a slice of pieces to an ASCII canvas
26
+ func (r *TanagramRenderer) RenderPiecesToCanvas(pieces []TangramPiece) [][]int {
27
+ canvas := make([][]int, r.height)
28
+ for i := range canvas {
29
+ canvas[i] = make([]int, r.width)
30
+ }
31
+
32
+ // Render each piece
33
+ for idx, piece := range pieces {
34
+ r.renderPieceToCanvas(&canvas, piece, idx)
35
+ }
36
+
37
+ return canvas
38
+ }
39
+
40
+ // renderPieceToCanvas renders a single piece to the canvas
41
+ func (r *TanagramRenderer) renderPieceToCanvas(canvas *[][]int, piece TangramPiece, pieceIndex int) {
42
+ points := r.getPiecePoints(piece)
43
+
44
+ for _, point := range points {
45
+ x := piece.Position.X + point.X
46
+ y := piece.Position.Y + point.Y
47
+
48
+ if y >= 0 && y < len(*canvas) && x >= 0 && x < len((*canvas)[0]) {
49
+ (*canvas)[y][x] = pieceIndex + 1
50
+ }
51
+ }
52
+ }
53
+
54
+ // getPiecePoints generates all points for a piece (handles rotation, flip, aspect ratio)
55
+ func (r *TanagramRenderer) getPiecePoints(piece TangramPiece) []Position {
56
+ var basePoints []Position
57
+
58
+ // Generate pieces in "true" square proportions
59
+ switch piece.Type {
60
+ case SmallTriangle:
61
+ height := 8
62
+ for y := 0; y < height; y++ {
63
+ for x := 0; x <= height-1-y; x++ {
64
+ basePoints = append(basePoints, Position{x, y})
65
+ }
66
+ }
67
+ case MediumTriangle:
68
+ height := 10
69
+ for y := 0; y < height; y++ {
70
+ for x := 0; x <= height-1-y; x++ {
71
+ basePoints = append(basePoints, Position{x, y})
72
+ }
73
+ }
74
+ case LargeTriangle:
75
+ height := 14
76
+ for y := 0; y < height; y++ {
77
+ for x := 0; x <= height-1-y; x++ {
78
+ basePoints = append(basePoints, Position{x, y})
79
+ }
80
+ }
81
+ case Square:
82
+ size := 9
83
+ for y := 0; y < size; y++ {
84
+ for x := 0; x < size; x++ {
85
+ basePoints = append(basePoints, Position{x, y})
86
+ }
87
+ }
88
+ case Parallelogram:
89
+ height := 5
90
+ baseLength := 11
91
+ for y := 0; y < height; y++ {
92
+ offset := height - 1 - y
93
+ for x := 0; x < baseLength; x++ {
94
+ basePoints = append(basePoints, Position{x + offset, y})
95
+ }
96
+ }
97
+ }
98
+
99
+ // Apply rotation
100
+ rotatedPoints := r.rotatePoints(basePoints, piece.Rotation)
101
+
102
+ // Fill gaps
103
+ rotatedPoints = r.fillShape(rotatedPoints)
104
+
105
+ // Apply flip
106
+ if piece.Flipped {
107
+ rotatedPoints = r.flipPoints(rotatedPoints)
108
+ }
109
+
110
+ // Apply aspect ratio correction
111
+ aspectCorrectedPoints := make([]Position, 0, len(rotatedPoints)*2)
112
+ for _, p := range rotatedPoints {
113
+ aspectCorrectedPoints = append(aspectCorrectedPoints, Position{p.X * 2, p.Y})
114
+ aspectCorrectedPoints = append(aspectCorrectedPoints, Position{p.X*2 + 1, p.Y})
115
+ }
116
+
117
+ return aspectCorrectedPoints
118
+ }
119
+
120
+ // rotatePoints rotates points by the given angle
121
+ func (r *TanagramRenderer) rotatePoints(points []Position, angle int) []Position {
122
+ if angle == 0 {
123
+ return points
124
+ }
125
+
126
+ var rotated []Position
127
+ rad := float64(angle) * math.Pi / 180.0
128
+ cos := math.Cos(rad)
129
+ sin := math.Sin(rad)
130
+
131
+ var sumX, sumY int
132
+ for _, p := range points {
133
+ sumX += p.X
134
+ sumY += p.Y
135
+ }
136
+ centerX := float64(sumX) / float64(len(points))
137
+ centerY := float64(sumY) / float64(len(points))
138
+
139
+ for _, p := range points {
140
+ x := float64(p.X) - centerX
141
+ y := float64(p.Y) - centerY
142
+
143
+ newX := x*cos - y*sin
144
+ newY := x*sin + y*cos
145
+
146
+ rotated = append(rotated, Position{
147
+ X: int(math.Round(newX + centerX)),
148
+ Y: int(math.Round(newY + centerY)),
149
+ })
150
+ }
151
+
152
+ // Normalize to positive coordinates
153
+ minX, minY := rotated[0].X, rotated[0].Y
154
+ for _, p := range rotated {
155
+ if p.X < minX {
156
+ minX = p.X
157
+ }
158
+ if p.Y < minY {
159
+ minY = p.Y
160
+ }
161
+ }
162
+
163
+ for i := range rotated {
164
+ rotated[i].X -= minX
165
+ rotated[i].Y -= minY
166
+ }
167
+
168
+ return rotated
169
+ }
170
+
171
+ // fillShape fills gaps in rotated shapes
172
+ func (r *TanagramRenderer) fillShape(points []Position) []Position {
173
+ if len(points) == 0 {
174
+ return points
175
+ }
176
+
177
+ minX, maxX := points[0].X, points[0].X
178
+ minY, maxY := points[0].Y, points[0].Y
179
+ for _, p := range points {
180
+ if p.X < minX {
181
+ minX = p.X
182
+ }
183
+ if p.X > maxX {
184
+ maxX = p.X
185
+ }
186
+ if p.Y < minY {
187
+ minY = p.Y
188
+ }
189
+ if p.Y > maxY {
190
+ maxY = p.Y
191
+ }
192
+ }
193
+
194
+ filledMap := make(map[Position]bool)
195
+
196
+ // Fill horizontally
197
+ for y := minY; y <= maxY; y++ {
198
+ var xCoords []int
199
+ for _, p := range points {
200
+ if p.Y == y {
201
+ xCoords = append(xCoords, p.X)
202
+ }
203
+ }
204
+ if len(xCoords) == 0 {
205
+ continue
206
+ }
207
+ rowMinX, rowMaxX := xCoords[0], xCoords[0]
208
+ for _, x := range xCoords {
209
+ if x < rowMinX {
210
+ rowMinX = x
211
+ }
212
+ if x > rowMaxX {
213
+ rowMaxX = x
214
+ }
215
+ }
216
+ for x := rowMinX; x <= rowMaxX; x++ {
217
+ filledMap[Position{x, y}] = true
218
+ }
219
+ }
220
+
221
+ // Fill vertically
222
+ for x := minX; x <= maxX; x++ {
223
+ var yCoords []int
224
+ for p := range filledMap {
225
+ if p.X == x {
226
+ yCoords = append(yCoords, p.Y)
227
+ }
228
+ }
229
+ if len(yCoords) == 0 {
230
+ continue
231
+ }
232
+ colMinY, colMaxY := yCoords[0], yCoords[0]
233
+ for _, y := range yCoords {
234
+ if y < colMinY {
235
+ colMinY = y
236
+ }
237
+ if y > colMaxY {
238
+ colMaxY = y
239
+ }
240
+ }
241
+ for y := colMinY; y <= colMaxY; y++ {
242
+ filledMap[Position{x, y}] = true
243
+ }
244
+ }
245
+
246
+ filledPoints := make([]Position, 0, len(filledMap))
247
+ for p := range filledMap {
248
+ filledPoints = append(filledPoints, p)
249
+ }
250
+
251
+ return filledPoints
252
+ }
253
+
254
+ // flipPoints flips points horizontally
255
+ func (r *TanagramRenderer) flipPoints(points []Position) []Position {
256
+ if len(points) == 0 {
257
+ return points
258
+ }
259
+
260
+ maxX := points[0].X
261
+ for _, p := range points {
262
+ if p.X > maxX {
263
+ maxX = p.X
264
+ }
265
+ }
266
+
267
+ var flipped []Position
268
+ for _, p := range points {
269
+ flipped = append(flipped, Position{
270
+ X: maxX - p.X,
271
+ Y: p.Y,
272
+ })
273
+ }
274
+
275
+ return flipped
276
+ }
277
+
278
+ // CanvasToString converts a canvas to a styled string
279
+ func (r *TanagramRenderer) CanvasToString(canvas [][]int, pieces []TangramPiece) string {
280
+ var lines []string
281
+ for y, row := range canvas {
282
+ var line string
283
+ for x := range row {
284
+ char, color := r.getCellDisplay(canvas, x, y, pieces)
285
+ if color != "" {
286
+ style := lipgloss.NewStyle().Foreground(color)
287
+ line += style.Render(string(char))
288
+ } else {
289
+ line += string(char)
290
+ }
291
+ }
292
+ lines = append(lines, line)
293
+ }
294
+ return lipgloss.JoinVertical(lipgloss.Left, lines...)
295
+ }
296
+
297
+ // getCellDisplay returns the character and color for a cell
298
+ func (r *TanagramRenderer) getCellDisplay(canvas [][]int, x, y int, pieces []TangramPiece) (rune, lipgloss.Color) {
299
+ pieceID := canvas[y][x]
300
+
301
+ if pieceID == 0 {
302
+ return ' ', ""
303
+ }
304
+
305
+ pieceIndex := pieceID - 1
306
+ if pieceIndex >= len(pieces) {
307
+ return ' ', ""
308
+ }
309
+
310
+ piece := pieces[pieceIndex]
311
+ return '█', piece.Color
312
+ }
313
+
314
+ // LoadTanagramConfig loads a tangram configuration from a JSON file
315
+ func LoadTanagramConfig(filename string) ([]TangramPiece, error) {
316
+ data, err := os.ReadFile(filename)
317
+ if err != nil {
318
+ return nil, err
319
+ }
320
+
321
+ type SavedPiece struct {
322
+ ID int `json:"id"`
323
+ Type string `json:"type"`
324
+ Position Position `json:"position"`
325
+ Rotation int `json:"rotation"`
326
+ Flipped bool `json:"flipped"`
327
+ }
328
+
329
+ var savedPieces []SavedPiece
330
+ err = json.Unmarshal(data, &savedPieces)
331
+ if err != nil {
332
+ return nil, err
333
+ }
334
+
335
+ // Color mapping for each piece ID
336
+ colors := map[int]lipgloss.Color{
337
+ 1: "#BA68C8", // Purple
338
+ 2: "#FFB74D", // Orange
339
+ 3: "#42A5F5", // Blue
340
+ 4: "#EF4444", // Red
341
+ 5: "#FF9800", // Orange
342
+ 6: "#7E57C2", // Purple
343
+ 7: "#66BB6A", // Green
344
+ }
345
+
346
+ var pieces []TangramPiece
347
+ for _, saved := range savedPieces {
348
+ pieces = append(pieces, TangramPiece{
349
+ ID: saved.ID,
350
+ Type: PieceType(saved.Type),
351
+ Color: colors[saved.ID],
352
+ Position: saved.Position,
353
+ Rotation: saved.Rotation,
354
+ Flipped: saved.Flipped,
355
+ })
356
+ }
357
+
358
+ return pieces, nil
359
+ }
package/tui/welcome.go ADDED
@@ -0,0 +1,186 @@
1
+ package tui
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+
7
+ tea "github.com/charmbracelet/bubbletea"
8
+ "github.com/charmbracelet/lipgloss"
9
+ )
10
+
11
+ // MenuChoice represents the user's selection
12
+ type MenuChoice int
13
+
14
+ const (
15
+ ChoiceNone MenuChoice = iota
16
+ ChoiceImportPolicies
17
+ ChoiceLocalMode
18
+ )
19
+
20
+ // WelcomeModel is the Bubble Tea model for the welcome screen
21
+ type WelcomeModel struct {
22
+ selectedIndex int
23
+ choice MenuChoice
24
+ width int
25
+ height int
26
+ }
27
+
28
+ // NewWelcomeModel creates a new welcome screen model
29
+ func NewWelcomeModel() WelcomeModel {
30
+ return WelcomeModel{
31
+ selectedIndex: 0,
32
+ choice: ChoiceNone,
33
+ }
34
+ }
35
+
36
+ // Init initializes the model
37
+ func (m WelcomeModel) Init() tea.Cmd {
38
+ return nil
39
+ }
40
+
41
+ // Update handles messages
42
+ func (m WelcomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
43
+ switch msg := msg.(type) {
44
+ case tea.KeyMsg:
45
+ switch msg.String() {
46
+ case "ctrl+c", "q", "esc":
47
+ return m, tea.Quit
48
+ case "up", "k":
49
+ if m.selectedIndex > 0 {
50
+ m.selectedIndex--
51
+ }
52
+ case "down", "j":
53
+ if m.selectedIndex < 1 {
54
+ m.selectedIndex++
55
+ }
56
+ case "enter", " ":
57
+ if m.selectedIndex == 0 {
58
+ m.choice = ChoiceImportPolicies
59
+ } else {
60
+ m.choice = ChoiceLocalMode
61
+ }
62
+ return m, tea.Quit
63
+ }
64
+ case tea.WindowSizeMsg:
65
+ m.width = msg.Width
66
+ m.height = msg.Height
67
+ }
68
+ return m, nil
69
+ }
70
+
71
+ // View renders the UI
72
+ func (m WelcomeModel) View() string {
73
+ // Load and render the tangram logo from config
74
+ var logo string
75
+
76
+ pieces, err := LoadTanagramConfig("tanagram-config.json")
77
+ if err == nil && len(pieces) > 0 {
78
+ // Render using the shared renderer
79
+ renderer := NewTanagramRenderer(90, 45)
80
+ canvas := renderer.RenderPiecesToCanvas(pieces)
81
+ logo = renderer.CanvasToString(canvas, pieces)
82
+ } else {
83
+ // Fallback to simple text if config not found
84
+ logo = "TANAGRAM"
85
+ }
86
+
87
+ // Title style
88
+ titleStyle := lipgloss.NewStyle().
89
+ Bold(true).
90
+ Foreground(lipgloss.Color("#FFFFFF")).
91
+ MarginTop(2).
92
+ MarginBottom(1).
93
+ Align(lipgloss.Center)
94
+
95
+ title := titleStyle.Render("TANAGRAM")
96
+
97
+ // Subtitle style
98
+ subtitleStyle := lipgloss.NewStyle().
99
+ Foreground(lipgloss.Color("#888888")).
100
+ Italic(true).
101
+ MarginBottom(2).
102
+ Align(lipgloss.Center)
103
+
104
+ subtitle := subtitleStyle.Render("Policy enforcement for git changes")
105
+
106
+ // Menu styles
107
+ selectedStyle := lipgloss.NewStyle().
108
+ Foreground(lipgloss.Color("#FFFFFF")).
109
+ Background(lipgloss.Color("#8B5CF6")).
110
+ Bold(true).
111
+ Padding(0, 2)
112
+
113
+ normalStyle := lipgloss.NewStyle().
114
+ Foreground(lipgloss.Color("#AAAAAA")).
115
+ Padding(0, 2)
116
+
117
+ // Menu options
118
+ options := []string{
119
+ "Import policies from Tanagram",
120
+ "Continue with local (no sign in)",
121
+ }
122
+
123
+ var menuItems []string
124
+ for i, opt := range options {
125
+ if i == m.selectedIndex {
126
+ menuItems = append(menuItems, " "+selectedStyle.Render("→ "+opt))
127
+ } else {
128
+ menuItems = append(menuItems, " "+normalStyle.Render(" "+opt))
129
+ }
130
+ }
131
+
132
+ menu := strings.Join(menuItems, "\n")
133
+
134
+ // Help text
135
+ helpStyle := lipgloss.NewStyle().
136
+ Foreground(lipgloss.Color("#666666")).
137
+ Italic(true).
138
+ MarginTop(2).
139
+ Align(lipgloss.Center)
140
+
141
+ help := helpStyle.Render("↑/↓ or j/k to navigate • enter/space to select • q/esc to quit")
142
+
143
+ // Combine all parts
144
+ content := lipgloss.JoinVertical(lipgloss.Center,
145
+ "",
146
+ logo,
147
+ "",
148
+ title,
149
+ subtitle,
150
+ "",
151
+ menu,
152
+ "",
153
+ help,
154
+ "",
155
+ )
156
+
157
+ // Center horizontally and vertically
158
+ containerStyle := lipgloss.NewStyle().
159
+ Width(m.width).
160
+ Height(m.height).
161
+ Align(lipgloss.Center, lipgloss.Center)
162
+
163
+ return containerStyle.Render(content)
164
+ }
165
+
166
+ // GetChoice returns the user's menu selection
167
+ func (m WelcomeModel) GetChoice() MenuChoice {
168
+ return m.choice
169
+ }
170
+
171
+ // RunWelcomeScreen displays the welcome screen and returns the user's choice
172
+ func RunWelcomeScreen() (MenuChoice, error) {
173
+ model := NewWelcomeModel()
174
+ p := tea.NewProgram(model, tea.WithAltScreen())
175
+
176
+ finalModel, err := p.Run()
177
+ if err != nil {
178
+ return ChoiceNone, fmt.Errorf("error running welcome screen: %w", err)
179
+ }
180
+
181
+ if m, ok := finalModel.(WelcomeModel); ok {
182
+ return m.GetChoice(), nil
183
+ }
184
+
185
+ return ChoiceNone, fmt.Errorf("unexpected model type")
186
+ }