├── .editorconfig
├── .github
└── workflows
│ └── go.yml
├── .gitignore
├── LICENSE
├── components
├── box
│ ├── component.go
│ ├── doc.go
│ ├── layout.go
│ ├── properties.go
│ └── render.go
├── doc.go
└── text
│ ├── component.go
│ ├── doc.go
│ ├── layout.go
│ ├── properties.go
│ └── render.go
├── doc.go
├── example
├── cmd
│ └── main.go
├── components
│ ├── ClickableBox.go
│ └── EffectExampleBox.go
├── doc.go
└── hackernews
│ ├── components
│ ├── app
│ │ └── app.go
│ ├── cache
│ │ └── cache.go
│ ├── common
│ │ └── hooks
│ │ │ └── hn
│ │ │ ├── client.go
│ │ │ ├── story.go
│ │ │ └── top.go
│ ├── menu
│ │ ├── item.go
│ │ └── menu.go
│ ├── story
│ │ └── story.go
│ └── theme
│ │ ├── context.go
│ │ └── themes.go
│ └── main.go
├── example_app_test.go
├── go.mod
├── go.sum
├── gopher.svg
├── icon.ico
├── index.html
├── logo.png
├── makefile
├── mvp.css
├── netlify.toml
├── r
├── debug
│ └── debug.go
├── doc.go
├── element.go
├── element_test.go
├── events.go
├── fiber.go
├── globals.go
├── hooks.go
├── internal
│ └── quadtree
│ │ ├── quadtree.go
│ │ └── quadtree_test.go
├── intmath
│ ├── doc.go
│ └── math.go
├── layout.go
├── render.go
├── retort.go
├── useContext.go
├── useEffect.go
├── useQuit.go
├── useScreen.go
└── useState.go
├── readme.md
├── redirect.html
└── todo.md
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | trim_trailing_whitespace = true
3 | insert_final_newline = true
4 | max_line_length = 80
5 |
6 |
7 | [*.html]
8 | max_line_length = 100
9 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: Go
2 | on: [push]
3 | jobs:
4 | build:
5 | name: Build
6 | runs-on: ubuntu-latest
7 | steps:
8 | - name: Set up Go 1.13
9 | uses: actions/setup-go@v1
10 | with:
11 | go-version: 1.13
12 | id: go
13 |
14 | - name: Check out code into the Go module directory
15 | uses: actions/checkout@v1
16 |
17 | - name: Get dependencies
18 | run: |
19 | go get -v -t -d ./...
20 | if [ -f Gopkg.toml ]; then
21 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
22 | dep ensure
23 | fi
24 |
25 | - name: build
26 | run: go build -v ./example/cmd
27 | - name: test
28 | run: go test ./...
29 | # golangci:
30 | # name: lint
31 | # runs-on: ubuntu-latest
32 | # steps:
33 | # - uses: actions/checkout@v2
34 | # - name: golangci-lint
35 | # uses: golangci/golangci-lint-action@v1
36 | # with:
37 | # version: v1.26
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .DS_STORE
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2019 Owen Kelly
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the "Software"), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7 | of the Software, and to permit persons to whom the Software is furnished to do
8 | so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/components/box/component.go:
--------------------------------------------------------------------------------
1 | package box
2 |
3 | import (
4 | "github.com/gdamore/tcell"
5 |
6 | "retort.dev/r"
7 | )
8 |
9 | // Box is the basic building block for a retort app.
10 | // Box implements the Box Model, see Properties
11 | func Box(p r.Properties) r.Element {
12 | screen := r.UseScreen()
13 |
14 | // Get our Properties
15 | boxProps := p.GetProperty(
16 | Properties{},
17 | "Box requires Properties",
18 | ).(Properties)
19 |
20 | // Get any children
21 | children := p.GetOptionalProperty(
22 | r.Children{},
23 | ).(r.Children)
24 |
25 | return r.CreateScreenElement(
26 | calculateBlockLayout(boxProps),
27 | func(s tcell.Screen, blockLayout r.BlockLayout) {
28 | if s == nil {
29 | panic("Box can't render no screen")
30 | }
31 |
32 | w, h := s.Size()
33 |
34 | if w == 0 || h == 0 {
35 | panic("Box can't render on a zero size screen")
36 | }
37 |
38 | render(
39 | screen,
40 | boxProps,
41 | blockLayout,
42 | )
43 |
44 | },
45 | r.Properties{},
46 | children,
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/components/box/doc.go:
--------------------------------------------------------------------------------
1 | /*
2 | package box contains Box a highly configurable building block
3 | for retort apps.
4 |
5 |
6 |
7 | */
8 | package box // import "retort.dev/components/box"
9 |
--------------------------------------------------------------------------------
/components/box/layout.go:
--------------------------------------------------------------------------------
1 | package box
2 |
3 | import (
4 | "math"
5 |
6 | "github.com/gdamore/tcell"
7 | "retort.dev/r"
8 | )
9 |
10 | func calculateBlockLayout(
11 | props Properties,
12 | ) r.CalculateLayout {
13 | return func(
14 | s tcell.Screen,
15 | stage r.CalculateLayoutStage,
16 | parentBlockLayout r.BlockLayout,
17 | children r.BlockLayouts,
18 | ) (
19 | outerBlockLayout r.BlockLayout,
20 | innerBlockLayout r.BlockLayout,
21 | childrenBlockLayouts r.BlockLayouts,
22 | ) {
23 | childrenBlockLayouts = children
24 | // debug.Spew(stage, children)
25 | switch stage {
26 | case r.CalculateLayoutStageInitial:
27 |
28 | // if any widths or heights are explicitly set, set them here
29 | // otherwise inherit from the parentBlockLayout
30 | // debug.Spew("CalculateLayoutStageInitial", parentBlockLayout)
31 | rows := parentBlockLayout.Rows
32 | columns := parentBlockLayout.Columns
33 |
34 | if props.Rows == 0 && props.Height != 0 {
35 | rows = int(
36 | math.Round(
37 | float64(parentBlockLayout.Rows) * float64(props.Height) / 100,
38 | ),
39 | )
40 | outerBlockLayout.FixedRows = true
41 | } else if props.Rows != 0 {
42 | rows = props.Rows
43 | outerBlockLayout.FixedRows = true
44 | }
45 |
46 | if props.Columns == 0 && props.Width != 0 {
47 | columns = int(
48 | math.Round(
49 | float64(parentBlockLayout.Columns) * float64(props.Width) / 100,
50 | ),
51 | )
52 | outerBlockLayout.FixedColumns = true
53 | } else if props.Columns != 0 {
54 | columns = props.Columns
55 | outerBlockLayout.FixedColumns = true
56 | }
57 |
58 | outerBlockLayout = r.BlockLayout{
59 | ZIndex: props.ZIndex,
60 | Rows: rows,
61 | Columns: columns,
62 | Grow: props.Grow,
63 | X: parentBlockLayout.X,
64 | Y: parentBlockLayout.Y,
65 | FixedColumns: outerBlockLayout.FixedColumns,
66 | FixedRows: outerBlockLayout.FixedRows,
67 | Valid: true,
68 | }
69 |
70 | // Calculate margin
71 | outerBlockLayout.X = parentBlockLayout.X + props.Margin.Left
72 | outerBlockLayout.Columns = outerBlockLayout.Columns - props.Margin.Right
73 | outerBlockLayout.Y = parentBlockLayout.Y + props.Margin.Top
74 | outerBlockLayout.Rows = outerBlockLayout.Rows - props.Margin.Bottom
75 |
76 | innerBlockLayout = r.BlockLayout{
77 | ZIndex: props.ZIndex,
78 | Rows: outerBlockLayout.Rows,
79 | Columns: outerBlockLayout.Columns,
80 | X: outerBlockLayout.X,
81 | Y: outerBlockLayout.Y,
82 | FixedColumns: outerBlockLayout.FixedColumns,
83 | FixedRows: outerBlockLayout.FixedRows,
84 | Valid: true,
85 | }
86 |
87 | innerBlockLayout.Columns = innerBlockLayout.Columns -
88 | props.Padding.Left - props.Padding.Right
89 |
90 | innerBlockLayout.Rows = innerBlockLayout.Rows -
91 | props.Padding.Top - props.Padding.Bottom
92 |
93 | // // Calculate padding box
94 | innerBlockLayout.Y = innerBlockLayout.Y + props.Padding.Top
95 | innerBlockLayout.Columns = innerBlockLayout.Columns - props.Padding.Right
96 | innerBlockLayout.Rows = innerBlockLayout.Rows - props.Padding.Bottom
97 | innerBlockLayout.X = innerBlockLayout.X + props.Padding.Left
98 |
99 | // Border Sizing
100 | if props.Border.Style != BorderStyleNone {
101 | outerBlockLayout.Columns = outerBlockLayout.Columns - 2 // 1 for each side
102 | outerBlockLayout.Rows = outerBlockLayout.Rows - 2 // 1 for each side
103 |
104 | // only one border type at the moment
105 | outerBlockLayout.Border.Top = 1
106 | outerBlockLayout.Border.Right = 1
107 | outerBlockLayout.Border.Bottom = 1
108 | outerBlockLayout.Border.Left = 1
109 |
110 | innerBlockLayout.X = innerBlockLayout.X + 1
111 | innerBlockLayout.Y = innerBlockLayout.Y + 1
112 | innerBlockLayout.Rows = innerBlockLayout.Rows - 2
113 | innerBlockLayout.Columns = innerBlockLayout.Columns - 2
114 | }
115 |
116 | // Ensure the rows and cols are not below 0
117 | if outerBlockLayout.Rows < 0 {
118 | outerBlockLayout.Rows = 0
119 | }
120 | if outerBlockLayout.Columns < 0 {
121 | outerBlockLayout.Columns = 0
122 | }
123 | if innerBlockLayout.Rows < 0 {
124 | innerBlockLayout.Rows = 0
125 | }
126 | if innerBlockLayout.Columns < 0 {
127 | innerBlockLayout.Columns = 0
128 | }
129 | // debug.Spew("CalculateLayoutStageInitial end outer", outerBlockLayout)
130 | // debug.Spew("CalculateLayoutStageInitial end inner", innerBlockLayout)
131 | return
132 | case r.CalculateLayoutStageWithChildren:
133 | if len(children) == 0 {
134 | return
135 | }
136 |
137 | // Look at all the children who have widths, and add them up
138 | // then split the remainder between those without widths
139 |
140 | innerBlockLayout = r.BlockLayout{
141 | ZIndex: props.ZIndex,
142 | Rows: parentBlockLayout.Rows,
143 | Columns: parentBlockLayout.Columns,
144 | X: parentBlockLayout.X,
145 | Y: parentBlockLayout.Y,
146 | Valid: true,
147 | }
148 |
149 | colsRemaining := innerBlockLayout.Columns
150 | rowsRemaining := innerBlockLayout.Rows
151 | growCount := 0
152 | growDivision := 0
153 |
154 | // Find all children with fixed row,col sizing, and count all grow's
155 | for _, c := range children {
156 | if c.FixedColumns {
157 | colsRemaining = colsRemaining - c.Columns
158 | }
159 | if c.FixedRows {
160 | rowsRemaining = rowsRemaining - c.Rows
161 | }
162 |
163 | growCount = growCount + c.Grow
164 |
165 | if c.Grow == 0 {
166 | growCount = growCount + 1 // we force grow to be at least 1
167 | }
168 | }
169 |
170 | // debug.Spew("growCount", growCount)
171 |
172 | switch props.Direction {
173 | case DirectionRow:
174 | growDivision = colsRemaining / growCount
175 | case DirectionRowReverse:
176 | growDivision = colsRemaining / growCount
177 | case DirectionColumn:
178 | growDivision = rowsRemaining / growCount
179 | case DirectionColumnReverse:
180 | growDivision = rowsRemaining / growCount
181 | }
182 |
183 | // debug.Spew("colsRemaining", colsRemaining)
184 | // debug.Spew("rowsRemaining", rowsRemaining)
185 | // debug.Spew("growDivision", growDivision)
186 |
187 | // Reverse the slices if needed
188 | if props.Direction == DirectionRowReverse ||
189 | props.Direction == DirectionColumnReverse {
190 | for i := len(children)/2 - 1; i >= 0; i-- {
191 | opp := len(children) - 1 - i
192 | children[i], children[opp] = children[opp], children[i]
193 | }
194 | }
195 |
196 | // Get our starting position
197 | x := innerBlockLayout.X
198 | y := innerBlockLayout.Y
199 |
200 | for i, c := range children {
201 | grow := c.Grow
202 |
203 | if c.Grow == 0 {
204 | grow = grow + 1 // we force grow to be at least 1
205 | }
206 |
207 | rows := 0
208 | columns := 0
209 |
210 | if !c.FixedColumns || !c.FixedRows {
211 | // Calculate the size of this block based on the direction of the parent
212 | switch props.Direction {
213 | case DirectionRow:
214 | columns = growDivision * grow
215 | rows = innerBlockLayout.Rows
216 | case DirectionRowReverse:
217 | columns = growDivision * grow
218 | rows = innerBlockLayout.Rows
219 | case DirectionColumn:
220 | columns = innerBlockLayout.Columns
221 | rows = growDivision * grow
222 | case DirectionColumnReverse:
223 | columns = innerBlockLayout.Columns
224 | rows = growDivision * grow
225 | }
226 | }
227 |
228 | // debug.Spew("c grow", rows, columns, grow, growDivision)
229 |
230 | // Ensure rows and columns aren't negative
231 | if rows < 0 {
232 | rows = 0
233 | }
234 | if columns < 0 {
235 | columns = 0
236 | }
237 |
238 | // if props.MinHeight != 0 {
239 | // rows = intmath.Min(rows, props.MinHeight)
240 | // }
241 | // if props.MinWidth != 0 {
242 | // columns = intmath.Min(columns, props.MinWidth)
243 | // }
244 |
245 | blockLayout := r.BlockLayout{
246 | X: x,
247 | Y: y,
248 | Rows: rows,
249 | Columns: columns,
250 | ZIndex: c.ZIndex,
251 | Order: i,
252 | Valid: true,
253 | }
254 |
255 | switch props.Direction {
256 | case DirectionRow:
257 | x = x + columns
258 | case DirectionRowReverse:
259 | x = x + columns
260 | case DirectionColumn:
261 | y = y + rows
262 | case DirectionColumnReverse:
263 | y = y + rows
264 | }
265 |
266 | childrenBlockLayouts[i] = blockLayout
267 | }
268 |
269 | // If we reversed them, reverse them back
270 | if props.Direction == DirectionRowReverse ||
271 | props.Direction == DirectionColumnReverse {
272 | for i := len(children)/2 - 1; i >= 0; i-- {
273 | opp := len(children) - 1 - i
274 | children[i], children[opp] = children[opp], children[i]
275 | }
276 | }
277 |
278 | case r.CalculateLayoutStageFinal:
279 |
280 | }
281 |
282 | return
283 | }
284 | }
285 |
286 | // func calculateOldBlockLayout(
287 | // screen tcell.Screen,
288 | // parentBlockLayout r.BlockLayout,
289 | // boxProps Properties,
290 | // ) (
291 | // blockLayout r.BlockLayout,
292 | // innerBlockLayout r.BlockLayout,
293 | // ) {
294 | // rows := parentBlockLayout.Rows
295 | // columns := parentBlockLayout.Columns
296 |
297 | // if rows == 0 && boxProps.Height != 0 {
298 | // rows = int(
299 | // math.Round(
300 | // float64(parentBlockLayout.Rows) * (boxProps.Height / 100),
301 | // ),
302 | // )
303 | // }
304 | // if columns == 0 && boxProps.Width != 0 {
305 | // columns = int(
306 | // math.Round(
307 | // float64(parentBlockLayout.Columns) * (boxProps.Width / 100),
308 | // ),
309 | // )
310 | // }
311 |
312 | // blockLayout = r.BlockLayout{
313 | // ZIndex: boxProps.ZIndex,
314 | // Rows: rows,
315 | // Columns: columns,
316 | // X: parentBlockLayout.X,
317 | // Y: parentBlockLayout.Y,
318 | // }
319 |
320 | // // Calculate box size
321 | // blockLayout.Columns = columns
322 | // blockLayout.Rows = rows
323 |
324 | // // Calculate margin
325 | // blockLayout.X = parentBlockLayout.X + boxProps.Margin.Left
326 | // blockLayout.Columns = blockLayout.Columns - boxProps.Margin.Right
327 | // blockLayout.Y = parentBlockLayout.Y + boxProps.Margin.Top
328 | // blockLayout.Rows = blockLayout.Rows - boxProps.Margin.Bottom
329 |
330 | // innerBlockLayout = r.BlockLayout{
331 | // ZIndex: boxProps.ZIndex,
332 | // Rows: blockLayout.Rows,
333 | // Columns: blockLayout.Columns,
334 | // X: blockLayout.X,
335 | // Y: blockLayout.Y,
336 | // }
337 |
338 | // innerBlockLayout.Columns = blockLayout.Columns -
339 | // boxProps.Padding.Left - boxProps.Padding.Right
340 |
341 | // innerBlockLayout.Rows = blockLayout.Rows -
342 | // boxProps.Padding.Top - boxProps.Padding.Bottom
343 |
344 | // // Calculate padding box
345 | // innerBlockLayout.Y = innerBlockLayout.Y + boxProps.Padding.Top
346 | // innerBlockLayout.Columns = innerBlockLayout.Columns - boxProps.Padding.Right
347 | // innerBlockLayout.Rows = innerBlockLayout.Rows - boxProps.Padding.Bottom
348 | // innerBlockLayout.X = innerBlockLayout.X + boxProps.Padding.Left
349 |
350 | // // Border Sizing
351 |
352 | // if boxProps.Border.Style != BorderStyleNone {
353 | // blockLayout.Columns = blockLayout.Columns - 2 // 1 for each side
354 | // blockLayout.Rows = blockLayout.Rows - 2 // 1 for each side
355 |
356 | // innerBlockLayout.X = innerBlockLayout.X + 1
357 | // innerBlockLayout.Y = innerBlockLayout.Y + 1
358 | // innerBlockLayout.Rows = innerBlockLayout.Rows - 1
359 | // innerBlockLayout.Columns = innerBlockLayout.Columns - 2
360 | // }
361 |
362 | // // Ensure the rows and cols are not below 0
363 | // if blockLayout.Rows < 0 {
364 | // blockLayout.Rows = 0
365 | // }
366 | // if blockLayout.Columns < 0 {
367 | // blockLayout.Columns = 0
368 | // }
369 | // if innerBlockLayout.Rows < 0 {
370 | // innerBlockLayout.Rows = 0
371 | // }
372 | // if innerBlockLayout.Columns < 0 {
373 | // innerBlockLayout.Columns = 0
374 | // }
375 | // return
376 | // }
377 |
378 | func calculateOldBlockLayoutForChildren(
379 | screen tcell.Screen,
380 | boxProps Properties,
381 | innerBlockLayout r.BlockLayout,
382 | children r.Children,
383 | ) r.Children {
384 | // if len(children) == 0 {
385 | // return children
386 | // }
387 |
388 | // propMap := map[r.Element]Properties{}
389 |
390 | // colsRemaining := innerBlockLayout.Columns
391 | // rowsRemaining := innerBlockLayout.Rows
392 | // flexGrowCount := 0
393 | // flexGrowDivision := 0
394 |
395 | // for _, c := range children {
396 | // if c == nil {
397 | // continue
398 | // }
399 |
400 | // propMap[c] = c.Properties.GetOptionalProperty(
401 | // Properties{},
402 | // ).(Properties)
403 | // }
404 |
405 | // // Find all children with fixed row,col sizing
406 | // for _, props := range propMap {
407 | // colsRemaining = colsRemaining - props.Columns
408 | // rowsRemaining = rowsRemaining - props.Rows
409 | // flexGrowCount = flexGrowCount + props.FlexGrow
410 |
411 | // if props.FlexGrow == 0 {
412 | // flexGrowCount = flexGrowCount + 1 // we force flex-grow to be at least 1
413 | // }
414 | // }
415 |
416 | // switch boxProps.FlexDirection {
417 | // case FlexDirectionRow:
418 | // flexGrowDivision = colsRemaining / flexGrowCount
419 | // case FlexDirectionRowReverse:
420 | // flexGrowDivision = colsRemaining / flexGrowCount
421 | // case FlexDirectionColumn:
422 | // flexGrowDivision = rowsRemaining / flexGrowCount
423 | // case FlexDirectionColumnReverse:
424 | // flexGrowDivision = rowsRemaining / flexGrowCount
425 |
426 | // }
427 |
428 | // // Reverse the slices if needed
429 | // if boxProps.FlexDirection == FlexDirectionRowReverse ||
430 | // boxProps.FlexDirection == FlexDirectionColumnReverse {
431 | // for i := len(children)/2 - 1; i >= 0; i-- {
432 | // opp := len(children) - 1 - i
433 | // children[i], children[opp] = children[opp], children[i]
434 | // }
435 | // }
436 |
437 | // x := innerBlockLayout.X
438 | // y := innerBlockLayout.Y
439 |
440 | // for i, el := range children {
441 | // if el == nil {
442 | // continue
443 | // }
444 |
445 | // props := propMap[el]
446 | // flexGrow := props.FlexGrow
447 |
448 | // if props.FlexGrow == 0 {
449 | // flexGrow = flexGrow + 1 // we force flex-grow to be at least 1
450 | // }
451 |
452 | // rows := 0
453 | // columns := 0
454 |
455 | // switch boxProps.FlexDirection {
456 | // case FlexDirectionRow:
457 | // columns = flexGrowDivision * flexGrow
458 | // rows = innerBlockLayout.Rows
459 | // case FlexDirectionRowReverse:
460 | // columns = flexGrowDivision * flexGrow
461 | // rows = innerBlockLayout.Rows
462 | // case FlexDirectionColumn:
463 | // columns = innerBlockLayout.Columns
464 | // rows = flexGrowDivision * flexGrow
465 | // case FlexDirectionColumnReverse:
466 | // columns = innerBlockLayout.Columns
467 | // rows = flexGrowDivision * flexGrow
468 | // }
469 |
470 | // // Ensure rows and columns aren't negative
471 | // if rows < 0 {
472 | // rows = 0
473 | // }
474 | // if columns < 0 {
475 | // columns = 0
476 | // }
477 |
478 | // if props.MinHeight != 0 {
479 | // rows = intmath.Min(rows, props.MinHeight)
480 | // }
481 | // if props.MinWidth != 0 {
482 | // columns = intmath.Min(columns, props.MinWidth)
483 | // }
484 |
485 | // blockLayout := r.BlockLayout{
486 | // X: x,
487 | // Y: y,
488 | // Rows: rows,
489 | // Columns: columns,
490 | // ZIndex: boxProps.ZIndex,
491 | // Order: i,
492 | // }
493 |
494 | // switch boxProps.FlexDirection {
495 | // case FlexDirectionRow:
496 | // x = x + columns
497 | // case FlexDirectionRowReverse:
498 | // x = x + columns
499 | // case FlexDirectionColumn:
500 | // y = y + rows
501 | // case FlexDirectionColumnReverse:
502 | // y = y + rows
503 | // }
504 |
505 | // el.Properties = r.ReplaceProps(el.Properties, blockLayout)
506 | // }
507 | return children
508 | }
509 |
510 | // calculateSizeOfChildren recurses down the tree until it finds
511 | // a single of multiple boxes, and calculates their size
512 | // func calculateSizeOfChildren(el r.Element) r.BlockLayout {
513 |
514 | // }
515 |
--------------------------------------------------------------------------------
/components/box/properties.go:
--------------------------------------------------------------------------------
1 | package box
2 |
3 | import "github.com/gdamore/tcell"
4 |
5 | // Properties are passed along with box.Box tocreate and configure a Box element
6 | //
7 | // Contents
8 | //
9 | // The contents of the Box is not rendered by this component
10 | //
11 | //
12 | // Box Sizing
13 | //
14 | // Box Sizing is Border Box only
15 | // Border and padding is accounted for inside the width and height, meaning
16 | // the Box can never be bigger than the width or height.
17 | type Properties struct {
18 | // ZIndex is the layer this Box is rendered on, with larger numbers appearing
19 | // on top.
20 | ZIndex int
21 |
22 | Align Align
23 |
24 | // Content Box
25 | // If neither Width,Height or Rows,Columns are set, it will be calculated
26 | // automatically When set this is the percentage width and height.
27 | // Ignored when Rows,Columns is not 0
28 | Width, Height int // 0 = auto
29 |
30 | // Set the size fixed in rows and columns.
31 | // Ignored if 0
32 | // If both Rows and Width are set Rows with be used.
33 | Rows, Columns int
34 |
35 | Grow int
36 |
37 | // Padding Box
38 | Padding Padding
39 | Margin Margin
40 |
41 | Direction Direction
42 |
43 | // Border
44 | Border Border
45 |
46 | Background tcell.Color
47 | Foreground tcell.Color
48 |
49 | Overflow Overflow
50 |
51 | MinHeight int
52 | MinWidth int
53 |
54 | // TODO: maybe expand labels to allow them to be top/bottom left, center, right
55 |
56 | // Title is a Label placed on the top border
57 | Title Label
58 |
59 | // Footer is a Label place on the bottom border
60 | Footer Label
61 | }
62 |
63 | // [ BoxModel Types ]-----------------------------------------------------------
64 |
65 | type Padding struct {
66 | Top int
67 | Right int
68 | Bottom int
69 | Left int
70 | }
71 |
72 | type Margin struct {
73 | Top int
74 | Right int
75 | Bottom int
76 | Left int
77 | }
78 |
79 | type Direction int
80 |
81 | const (
82 | DirectionRow Direction = iota
83 | DirectionRowReverse
84 | DirectionColumn
85 | DirectionColumnReverse
86 | )
87 |
88 | type Border struct {
89 | Style BorderStyle
90 | Background tcell.Color
91 | Foreground tcell.Color
92 | }
93 |
94 | type BorderStyle int
95 |
96 | const (
97 | BorderStyleNone BorderStyle = iota
98 | BorderStyleSingle
99 | BorderStyleDouble
100 | BorderStyleBox // Box drawing characters
101 | )
102 |
103 | type Overflow int
104 |
105 | const (
106 | OverflowScroll Overflow = iota
107 | OverflowNone
108 | OverflowScrollX
109 | OverflowScrollY
110 | )
111 |
112 | type Align int
113 |
114 | const (
115 | AlignAuto Align = iota
116 | AlignStart
117 | AlignCenter
118 | AlignEnd
119 | )
120 |
121 | // [ Labels ]-------------------------------------------------------------------
122 |
123 | type LabelWrap int
124 |
125 | const (
126 | LabelWrapNone LabelWrap = iota
127 | LabelWrapBracket
128 | LabelWrapBrace
129 | LabelWrapChevron
130 | LabelWrapSquareBracket
131 | )
132 |
133 | // Label is a decorative string that can be added to the top or bottom border
134 | //
135 | // Margin allows you to move the whole label around, while Padding allows you
136 | // to define the gap between the Wrap and Value.
137 | // If no Padding is specified a single column is still added to each side of the
138 | // Value.
139 | type Label struct {
140 | Value string
141 | Wrap LabelWrap
142 | Align Align
143 | Margin Margin
144 | Padding Padding
145 | }
146 |
--------------------------------------------------------------------------------
/components/box/render.go:
--------------------------------------------------------------------------------
1 | package box
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/gdamore/tcell"
8 | runewidth "github.com/mattn/go-runewidth"
9 | "retort.dev/r"
10 | )
11 |
12 | func render(
13 | s tcell.Screen,
14 | props Properties,
15 | layout r.BlockLayout,
16 | ) {
17 | // debug.Spew("render", layout)
18 | x1 := layout.X
19 | y1 := layout.Y
20 | x2 := layout.X + layout.Columns
21 | y2 := layout.Y + layout.Rows
22 |
23 | borderStyle := tcell.StyleDefault
24 | borderStyle = borderStyle.Foreground(props.Border.Foreground)
25 | gl := ' '
26 |
27 | if y2 < y1 {
28 | y1, y2 = y2, y1
29 | }
30 | if x2 < x1 {
31 | x1, x2 = x2, x1
32 | }
33 |
34 | if props.Border.Style != BorderStyleNone {
35 | for col := x1; col <= x2; col++ {
36 | s.SetContent(col, y1, tcell.RuneHLine, nil, borderStyle)
37 | s.SetContent(col, y2, tcell.RuneHLine, nil, borderStyle)
38 | }
39 | for row := y1 + 1; row < y2; row++ {
40 | s.SetContent(x1, row, tcell.RuneVLine, nil, borderStyle)
41 | s.SetContent(x2, row, tcell.RuneVLine, nil, borderStyle)
42 | }
43 | if y1 != y2 && x1 != x2 {
44 | // Only add corners if we need to
45 | s.SetContent(x1, y1, tcell.RuneULCorner, nil, borderStyle)
46 | s.SetContent(x2, y1, tcell.RuneURCorner, nil, borderStyle)
47 | s.SetContent(x1, y2, tcell.RuneLLCorner, nil, borderStyle)
48 | s.SetContent(x2, y2, tcell.RuneLRCorner, nil, borderStyle)
49 | }
50 | for row := y1 + 1; row < y2; row++ {
51 | for col := x1 + 1; col < x2; col++ {
52 | s.SetContent(col, row, gl, nil, borderStyle)
53 | }
54 | }
55 | }
56 |
57 | if props.Title.Value != "" {
58 | renderLabel(
59 | s,
60 | props.Title,
61 | r.BlockLayout{
62 | X: layout.X + 2, // Bump it over 1 for the corner, and 1 for style
63 | Y: layout.Y,
64 | Rows: 1,
65 | Columns: layout.Columns,
66 | },
67 | borderStyle,
68 | )
69 | }
70 | if props.Footer.Value != "" {
71 | renderLabel(
72 | s,
73 | props.Footer,
74 | r.BlockLayout{
75 | X: layout.X + 2, // Bump it over 1 for the corner, and 1 for style
76 | Y: layout.Y + layout.Rows,
77 | Rows: 1,
78 | Columns: layout.Columns,
79 | },
80 | borderStyle,
81 | )
82 | }
83 | }
84 |
85 | func renderLabel(
86 | s tcell.Screen,
87 | label Label,
88 | layout r.BlockLayout,
89 | style tcell.Style,
90 | ) {
91 |
92 | i := 0
93 | var deferred []rune
94 | dwidth := 0
95 | isZeroWidthJoiner := false
96 |
97 | wrapLeft := ""
98 | wrapRight := ""
99 |
100 | switch label.Wrap {
101 | case LabelWrapNone:
102 | wrapLeft = " "
103 | wrapRight = " "
104 | case LabelWrapBrace:
105 | wrapLeft = "{ "
106 | wrapRight = " }"
107 | case LabelWrapBracket:
108 | wrapLeft = "( "
109 | wrapRight = " )"
110 | case LabelWrapSquareBracket:
111 | wrapLeft = "[ "
112 | wrapRight = " ]"
113 | case LabelWrapChevron:
114 | wrapLeft = "< "
115 | wrapRight = " >"
116 | }
117 |
118 | wrapLeft = fmt.Sprintf(
119 | "%s%s%s",
120 | strings.Repeat(" ", label.Margin.Left),
121 | wrapLeft,
122 | strings.Repeat(" ", label.Padding.Left),
123 | )
124 | wrapRight = fmt.Sprintf(
125 | "%s%s%s",
126 | strings.Repeat(" ", label.Margin.Right),
127 | wrapRight,
128 | strings.Repeat(" ", label.Padding.Right),
129 | )
130 |
131 | value := fmt.Sprintf("%s%s%s", wrapLeft, label.Value, wrapRight)
132 |
133 | // Print each rune to the screen
134 | for _, r := range value {
135 | // Check if the rune is a Zero Width Joiner
136 | if r == '\u200d' {
137 | if len(deferred) == 0 {
138 | deferred = append(deferred, ' ')
139 | dwidth = 1
140 | }
141 | deferred = append(deferred, r)
142 | isZeroWidthJoiner = true
143 | continue
144 | }
145 |
146 | if isZeroWidthJoiner {
147 | deferred = append(deferred, r)
148 | isZeroWidthJoiner = false
149 | continue
150 | }
151 |
152 | switch runewidth.RuneWidth(r) {
153 | case 0:
154 | if len(deferred) == 0 {
155 | deferred = append(deferred, ' ')
156 | dwidth = 1
157 | }
158 | case 1:
159 | if len(deferred) != 0 {
160 | s.SetContent(layout.X+i, layout.Y, deferred[0], deferred[1:], style)
161 | i += dwidth
162 | }
163 | deferred = nil
164 | dwidth = 1
165 | case 2:
166 | if len(deferred) != 0 {
167 | s.SetContent(layout.X+i, layout.Y, deferred[0], deferred[1:], style)
168 | i += dwidth
169 | }
170 | deferred = nil
171 | dwidth = 2
172 | }
173 | deferred = append(deferred, r)
174 | }
175 | if len(deferred) != 0 {
176 | s.SetContent(layout.X+i, layout.Y, deferred[0], deferred[1:], style)
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/components/doc.go:
--------------------------------------------------------------------------------
1 | //
2 | //
3 | // How to create custom Components
4 | //
5 | // By convention if a Component (lets call it Box) needs Properties the package
6 | // should have a struct called BoxProps.
7 | //
8 | //
9 | // As a general starting point many of the core Components are designed to
10 | // be reference implementations of retort's features.
11 | //
12 | // package component has common low level components for you to build with
13 | package component // import "retort.dev/component"
14 |
--------------------------------------------------------------------------------
/components/text/component.go:
--------------------------------------------------------------------------------
1 | package text
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/gdamore/tcell"
7 | "retort.dev/components/box"
8 | "retort.dev/r"
9 | "retort.dev/r/intmath"
10 | )
11 |
12 | type boxState struct {
13 | OffsetX, OffsetY int
14 | lastUpdated time.Time
15 | }
16 |
17 | // Text is the basic building block for a retort app.
18 | // Text implements the Text Model, see Properties
19 | func Text(p r.Properties) r.Element {
20 | // screen := r.UseScreen()
21 |
22 | // Get our Properties
23 | textProps := p.GetProperty(
24 | Properties{},
25 | "Text requires Properties",
26 | ).(Properties)
27 |
28 | // Get our Properties
29 | boxProps := p.GetOptionalProperty(
30 | box.Properties{},
31 | ).(box.Properties)
32 |
33 | // Get our BlockLayout
34 | parentBlockLayout := p.GetProperty(
35 | r.BlockLayout{},
36 | "Text requires a parent BlockLayout.",
37 | ).(r.BlockLayout)
38 |
39 | // Get any children
40 | children := p.GetOptionalProperty(
41 | r.Children{},
42 | ).(r.Children)
43 | if len(children) != 0 {
44 | panic("Text cannot have children")
45 | }
46 |
47 | s, setState := r.UseState(r.State{
48 | boxState{lastUpdated: time.Now()},
49 | })
50 | state := s.GetState(
51 | boxState{},
52 | ).(boxState)
53 |
54 | // // Calculate the BlockLayout of this Text
55 | // BlockLayout := calculateBlockLayout(
56 | // screen,
57 | // parentBlockLayout,
58 | // textProps,
59 | // )
60 |
61 | mouseEventHandler := func(up, down, left, right bool) {
62 | now := time.Now()
63 |
64 | if now.Sub(state.lastUpdated) < 16*time.Millisecond {
65 | // throttle to one update a second
66 | return
67 | }
68 |
69 | setState(func(s r.State) r.State {
70 | state := s.GetState(
71 | boxState{},
72 | ).(boxState)
73 |
74 | offsetXDelta := 0
75 | offsetYDelta := 0
76 |
77 | switch {
78 | case up:
79 | offsetXDelta = -1
80 | if state.OffsetX == 0 {
81 | return r.State{state}
82 | }
83 | case down:
84 | offsetXDelta = 1
85 | case left:
86 | offsetYDelta = -1
87 | if state.OffsetY == 0 {
88 | return r.State{state}
89 | }
90 |
91 | case right:
92 | offsetYDelta = 1
93 | }
94 |
95 | if offsetXDelta == 0 && offsetYDelta == 0 {
96 | return r.State{state}
97 | }
98 |
99 | offsetX := state.OffsetX
100 | offsetY := state.OffsetY
101 |
102 | if boxProps.Overflow == box.OverflowScroll ||
103 | boxProps.Overflow == box.OverflowScrollX {
104 | // When the offset is near the top, we just set the value
105 | // this prevents issues with the float64 conversion below
106 | // that was casuing jankiness
107 | if state.OffsetX < 3 {
108 | offsetX = state.OffsetX + offsetXDelta
109 | } else {
110 | offsetX = intmath.Min(
111 | intmath.Abs(state.OffsetX+offsetXDelta),
112 | int(float64(parentBlockLayout.Columns)/0.2),
113 | )
114 | }
115 | }
116 |
117 | if boxProps.Overflow == box.OverflowScroll ||
118 | boxProps.Overflow == box.OverflowScrollY {
119 | if offsetY < 3 {
120 | offsetY = state.OffsetY + offsetYDelta
121 | } else {
122 | offsetY = intmath.Min(
123 | intmath.Abs(state.OffsetY+offsetYDelta),
124 | int(float64(parentBlockLayout.Rows)/0.2),
125 | )
126 | }
127 | }
128 |
129 | return r.State{boxState{
130 | OffsetX: offsetX,
131 | OffsetY: offsetY,
132 | lastUpdated: time.Now(),
133 | },
134 | }
135 | })
136 | }
137 |
138 | props := r.Properties{}
139 |
140 | if boxProps.Overflow != box.OverflowNone {
141 | props = append(props, mouseEventHandler)
142 |
143 | }
144 |
145 | // debug.Spew("text", props)
146 |
147 | return r.CreateElement(
148 | box.Box,
149 | r.Properties{
150 | boxProps,
151 | mouseEventHandler,
152 | },
153 | r.Children{
154 | r.CreateScreenElement(
155 | calculateBlockLayout(textProps, boxProps),
156 | func(s tcell.Screen, blockLayout r.BlockLayout) {
157 | if s == nil {
158 | panic("Text can't render no screen")
159 | }
160 |
161 | w, h := s.Size()
162 |
163 | if w == 0 || h == 0 {
164 | panic("Text can't render on a zero size screen")
165 | }
166 |
167 | // debug.Spew("render text", blockLayout)
168 |
169 | render(
170 | s,
171 | textProps,
172 | blockLayout,
173 | state.OffsetX, state.OffsetY,
174 | )
175 | },
176 | r.Properties{},
177 | nil,
178 | ),
179 | },
180 | )
181 | }
182 |
--------------------------------------------------------------------------------
/components/text/doc.go:
--------------------------------------------------------------------------------
1 | package text // import "retort.dev/component/text"
2 |
--------------------------------------------------------------------------------
/components/text/layout.go:
--------------------------------------------------------------------------------
1 | package text
2 |
3 | import (
4 | "github.com/gdamore/tcell"
5 | "retort.dev/components/box"
6 | "retort.dev/r"
7 | "retort.dev/r/debug"
8 | )
9 |
10 | func calculateBlockLayout(
11 | textProps Properties,
12 | boxProps box.Properties,
13 | ) r.CalculateLayout {
14 | return func(
15 | s tcell.Screen,
16 | stage r.CalculateLayoutStage,
17 | parentBlockLayout r.BlockLayout,
18 | children r.BlockLayouts,
19 | ) (
20 | outerBlockLayout r.BlockLayout,
21 | innerBlockLayout r.BlockLayout,
22 | childrenBlockLayouts r.BlockLayouts,
23 | ) {
24 | outerBlockLayout = parentBlockLayout
25 | innerBlockLayout = parentBlockLayout
26 | childrenBlockLayouts = children
27 | // debug.Spew(stage, outerBlockLayout)
28 |
29 | switch stage {
30 | case r.CalculateLayoutStageInitial:
31 |
32 | lines := breakLines(textProps, innerBlockLayout)
33 | rows := len(lines)
34 |
35 | debug.Spew("rows", rows, textProps, innerBlockLayout)
36 |
37 | outerBlockLayout.Rows = rows
38 | outerBlockLayout.FixedRows = true
39 | innerBlockLayout.Rows = rows
40 | innerBlockLayout.FixedRows = true
41 | case r.CalculateLayoutStageWithChildren:
42 | case r.CalculateLayoutStageFinal:
43 |
44 | }
45 |
46 | return
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/components/text/properties.go:
--------------------------------------------------------------------------------
1 | package text
2 |
3 | import "github.com/gdamore/tcell"
4 |
5 | type Properties struct {
6 | Value string
7 | Overflow Overflow
8 | WordBreak WordBreak
9 |
10 | Background tcell.Color
11 | Foreground tcell.Color
12 | }
13 |
14 | // Overflow controls if the text is allowed to spill outside it's contained
15 | // While WordBreak controls what to do in the case of OverflowWrap
16 | type Overflow int
17 |
18 | const (
19 | OverflowWrap Overflow = iota
20 | OverflowElipsis
21 | OverflowHidden
22 | )
23 |
24 | type Align int
25 |
26 | const (
27 | AlignLeft Align = iota
28 | AlignCenter
29 | AlignRight
30 | )
31 |
32 | // WordBreak controls what happens to text greater than its width when
33 | // OverflowWrap is selected.
34 | type WordBreak int
35 |
36 | const (
37 | // Normal Use the default line break rule.
38 | Normal WordBreak = iota
39 | // BreakAll To prevent overflow, word breaks should be inserted between any
40 | // two characters (excluding Chinese/Japanese/Korean text).
41 | BreakAll
42 | // KeepAll Word breaks should not be used for Chinese/Japanese/Korean (CJK)
43 | // text. Non-CJK text behavior is the same as for normal.
44 | KeepAll
45 | )
46 |
--------------------------------------------------------------------------------
/components/text/render.go:
--------------------------------------------------------------------------------
1 | package text
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/gdamore/tcell"
7 | runewidth "github.com/mattn/go-runewidth"
8 | "retort.dev/r"
9 | "retort.dev/r/debug"
10 | "retort.dev/r/intmath"
11 | )
12 |
13 | func render(
14 | s tcell.Screen,
15 | props Properties,
16 | layout r.BlockLayout,
17 | offsetX, offsetY int,
18 | ) {
19 | debug.Spew("render", props, layout)
20 | style := tcell.StyleDefault
21 | style = style.Foreground(props.Foreground)
22 |
23 | lines := breakLines(props, layout)
24 |
25 | scrollLimit := int(float64(len(lines)) / 1.2)
26 | offset := 0
27 | if offsetX < len(lines) {
28 | offset = offsetX
29 | }
30 | if offsetX > scrollLimit {
31 | offset = scrollLimit
32 | }
33 |
34 | linesToRender := lines[offset:]
35 |
36 | for i, line := range linesToRender {
37 | if i > layout.Rows {
38 | return
39 | }
40 |
41 | renderLine(s, style, layout.X, layout.Y+i, line)
42 | }
43 | }
44 |
45 | func breakLines(
46 | props Properties,
47 | layout r.BlockLayout,
48 | ) (lines []string) {
49 | for _, text := range strings.Split(props.Value, "\n") {
50 | lines = append(lines, breakText(text, props, layout)...)
51 | }
52 | return
53 | }
54 |
55 | // breakText into rows to text that can be printed.
56 | // This function handles all logic related to word breaking.
57 | func breakText(
58 | text string,
59 | props Properties,
60 | layout r.BlockLayout,
61 | ) (lines []string) {
62 | width := layout.Columns
63 |
64 | // Break up words by whitespace characters
65 | words := strings.Fields(text)
66 |
67 | // if there's no words bail here
68 | if len(words) == 0 {
69 | return
70 | }
71 |
72 | line := ""
73 | colsRemaining := width
74 |
75 | for _, word := range words {
76 | if colsRemaining == 0 {
77 | // Save this line
78 | lines = append(lines, line)
79 |
80 | // And make a new one
81 | line = word
82 | colsRemaining = width
83 | continue
84 | }
85 |
86 | if len(word)+2 > colsRemaining {
87 | // Can we break the word?
88 | if props.WordBreak == BreakAll {
89 | lengthToSplit := intmath.Min(len(word), colsRemaining-1)
90 | // TODO: this isn't great, and could be greatly improved
91 | wordPart := word[:lengthToSplit] + "-"
92 | line = line + wordPart
93 | word = word[lengthToSplit:]
94 | }
95 |
96 | // Save this line
97 | lines = append(lines, line)
98 |
99 | // And make a new one
100 | line = word
101 | colsRemaining = width
102 | continue
103 | }
104 |
105 | line = line + word + " "
106 | colsRemaining = colsRemaining - len(word) - 1
107 | if colsRemaining < 0 {
108 | colsRemaining = 0
109 | }
110 | }
111 |
112 | // TODO: there's probably a better way
113 | // save last line
114 | lines = append(lines, line)
115 |
116 | return
117 | }
118 |
119 | func renderLine(s tcell.Screen, style tcell.Style, x, y int, str string) {
120 | // debug.Spew("renderLine", str)
121 | i := 0
122 | var deferred []rune
123 | dwidth := 0
124 | zwj := false
125 | for _, r := range str {
126 | if r == '\u200d' {
127 | if len(deferred) == 0 {
128 | deferred = append(deferred, ' ')
129 | dwidth = 1
130 | }
131 | deferred = append(deferred, r)
132 | zwj = true
133 | continue
134 | }
135 | if zwj {
136 | deferred = append(deferred, r)
137 | zwj = false
138 | continue
139 | }
140 | switch runewidth.RuneWidth(r) {
141 | case 0:
142 | if len(deferred) == 0 {
143 | deferred = append(deferred, ' ')
144 | dwidth = 1
145 | }
146 | case 1:
147 | if len(deferred) != 0 {
148 | s.SetContent(x+i, y, deferred[0], deferred[1:], style)
149 | i += dwidth
150 | }
151 | deferred = nil
152 | dwidth = 1
153 | case 2:
154 | if len(deferred) != 0 {
155 | s.SetContent(x+i, y, deferred[0], deferred[1:], style)
156 | i += dwidth
157 | }
158 | deferred = nil
159 | dwidth = 2
160 | }
161 | deferred = append(deferred, r)
162 | }
163 | if len(deferred) != 0 {
164 | s.SetContent(x+i, y, deferred[0], deferred[1:], style)
165 | i += dwidth
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/doc.go:
--------------------------------------------------------------------------------
1 | /*
2 | Retort is a reactive terminal user interface framework for golang.
3 |
4 | Inspired by React, the API is somewhat similar, but due to langauge differences
5 | they are not the same thing.
6 |
7 | Components
8 |
9 | An app built with retort is composed of components.
10 |
11 | Hooks
12 |
13 | Retort uses hooks to provide the functionality to make your Components
14 | interactive and responsive to user input and data.
15 |
16 | There are a few built in hooks, which can also be used to create custom hooks.
17 |
18 | UseState: use this to keep track of, and change state that is local to a
19 | component
20 |
21 | UseEffect: use this to do something (like setState) in a goroutine, for example
22 | fetch data
23 |
24 | UseScreen: use this to access the screen object directly. You probably wont
25 | need this, but it's there if you do for example if you want to create a new
26 | ScreenElement.
27 |
28 | UseQuit: use this to exit the application.
29 |
30 | Why
31 |
32 | As stated by the inspiration for this package "Declarative views make your code
33 | more predictable and easier to debug.Log". The original author (Owen Kelly) has
34 | years of experience building complex websites with React, and wanted a similar
35 | reactive/declarative tool for terminal user interfaces in golang.
36 |
37 | The biggest reason though, is state management.
38 |
39 | When you build an interactive user interface, the biggest challenge is always
40 | state management. The model that a reactive framework like retort (and React
41 | before it) allows, is one of the simplest ways to solve the state management
42 | problem. Much moreso than an imperitive user interface library.
43 |
44 | About the Name
45 |
46 | retort: to answer, or react to, an argument by a counter argument
47 |
48 | Terminals usually have arguments, often with their user.
49 |
50 | Don't think about it too much.
51 |
52 | Examples
53 |
54 | Below are some simple examples of how to use retort
55 |
56 | */
57 | package retort // import "retort.dev"
58 |
--------------------------------------------------------------------------------
/example/cmd/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/gdamore/tcell"
5 |
6 | "retort.dev/components/box"
7 | "retort.dev/example/components"
8 | "retort.dev/r"
9 | )
10 |
11 | const loremIpsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Enim praesent elementum facilisis leo. Et odio pellentesque diam volutpat commodo sed egestas. Risus viverra adipiscing at in tellus. Ornare suspendisse sed nisi lacus sed. Malesuada nunc vel risus commodo viverra maecenas accumsan lacus vel. Sit amet facilisis magna etiam. Bibendum neque egestas congue quisque egestas. Praesent tristique magna sit amet purus. Auctor eu augue ut lectus arcu bibendum at. Urna cursus eget nunc scelerisque viverra mauris in aliquam. Elit at imperdiet dui accumsan sit amet nulla. Sed euismod nisi porta lorem mollis aliquam ut porttitor. Volutpat diam ut venenatis tellus in metus vulputate eu scelerisque. Pharetra pharetra massa massa ultricies mi quis. Porta non pulvinar neque laoreet suspendisse interdum consectetur. Suspendisse in est ante in nibh mauris cursus mattis. Velit ut tortor pretium viverra suspendisse. Interdum varius sit amet mattis vulputate enim nulla.
12 |
13 | Venenatis urna cursus eget nunc scelerisque viverra. Libero enim sed faucibus turpis in eu mi bibendum neque. Mi in nulla posuere sollicitudin aliquam ultrices sagittis. Sagittis purus sit amet volutpat. Maecenas ultricies mi eget mauris pharetra et. Ac tortor vitae purus faucibus ornare. Sollicitudin ac orci phasellus egestas tellus rutrum tellus pellentesque. Imperdiet dui accumsan sit amet nulla. Semper feugiat nibh sed pulvinar proin gravida hendrerit lectus. Cras semper auctor neque vitae tempus quam pellentesque nec nam. Cursus sit amet dictum sit amet justo. Aenean vel elit scelerisque mauris pellentesque pulvinar pellentesque habitant. Lacinia at quis risus sed vulputate odio ut.
14 |
15 | Sed turpis tincidunt id aliquet. In aliquam sem fringilla ut morbi tincidunt. Pharetra convallis posuere morbi leo urna. Velit euismod in pellentesque massa. Pellentesque massa placerat duis ultricies lacus sed turpis tincidunt id. Risus quis varius quam quisque id diam. Urna condimentum mattis pellentesque id. Id interdum velit laoreet id donec ultrices tincidunt. At auctor urna nunc id cursus metus. Adipiscing diam donec adipiscing tristique risus nec. Ut porttitor leo a diam sollicitudin tempor. Est sit amet facilisis magna etiam. Tellus mauris a diam maecenas sed enim ut.
16 |
17 | Et malesuada fames ac turpis egestas. Egestas dui id ornare arcu odio ut sem nulla. Gravida cum sociis natoque penatibus et magnis dis. Tellus in hac habitasse platea. Ultrices tincidunt arcu non sodales. Lorem ipsum dolor sit amet consectetur. Egestas tellus rutrum tellus pellentesque. Ac auctor augue mauris augue neque gravida in fermentum et. Iaculis at erat pellentesque adipiscing commodo. Malesuada fames ac turpis egestas integer eget aliquet nibh praesent. Sit amet consectetur adipiscing elit ut aliquam purus sit amet. Vitae tortor condimentum lacinia quis vel eros donec ac. Purus faucibus ornare suspendisse sed nisi. Mi ipsum faucibus vitae aliquet nec ullamcorper sit amet. Ac turpis egestas sed tempus urna. Nibh venenatis cras sed felis eget velit. Sit amet purus gravida quis blandit turpis cursus in.
18 |
19 | Amet est placerat in egestas erat imperdiet sed euismod. Eget felis eget nunc lobortis. Ac auctor augue mauris augue neque. Ac tortor vitae purus faucibus ornare suspendisse. Placerat duis ultricies lacus sed. Tortor vitae purus faucibus ornare suspendisse sed nisi. Vulputate dignissim suspendisse in est ante in nibh. Elit duis tristique sollicitudin nibh sit. Tellus at urna condimentum mattis pellentesque id nibh tortor. Proin fermentum leo vel orci porta non pulvinar neque. Eu ultrices vitae auctor eu augue ut. Erat pellentesque adipiscing commodo elit at imperdiet. Auctor elit sed vulputate mi sit amet mauris. Tellus orci ac auctor augue mauris.`
20 |
21 | func main() {
22 | r.Retort(
23 | r.CreateElement(
24 | box.Box,
25 | r.Properties{
26 | box.Properties{
27 | // Width: 100,
28 | // Height: 100,
29 | Direction: box.DirectionColumn,
30 | Border: box.Border{
31 | Style: box.BorderStyleSingle,
32 | Foreground: tcell.ColorWhite,
33 | },
34 | Title: box.Label{
35 | Value: "Wrapper",
36 | },
37 | },
38 | },
39 | r.Children{
40 | // r.CreateElement(
41 | // box.Box,
42 | // r.Properties{
43 | // box.Properties{
44 | // Foreground: tcell.ColorBeige,
45 | // Grow: 3,
46 | // Border: box.Border{
47 | // Style: box.BorderStyleSingle,
48 | // Foreground: tcell.ColorWhite,
49 | // },
50 | // Padding: box.Padding{
51 | // Top: 0,
52 | // Right: 0,
53 | // Bottom: 0,
54 | // Left: 0,
55 | // },
56 | // Title: box.Label{
57 | // Value: "Example",
58 | // },
59 | // ZIndex: 5,
60 | // Overflow: box.OverflowScrollX,
61 | // },
62 | // },
63 | // r.Children{
64 | // r.CreateElement(
65 | // text.Text,
66 | // r.Properties{
67 | // text.Properties{
68 | // Value: loremIpsum,
69 | // WordBreak: text.BreakAll,
70 | // Foreground: tcell.ColorWhite,
71 | // },
72 | // },
73 | // nil,
74 | // ),
75 | // },
76 | // ),
77 | // r.CreateElement(
78 | // components.ClickableBox,
79 | // r.Properties{
80 | // box.Properties{
81 | // Grow: 1,
82 | // Foreground: tcell.ColorCadetBlue,
83 | // Border: box.Border{
84 | // Style: box.BorderStyleSingle,
85 | // Foreground: tcell.ColorWhite,
86 | // },
87 | // },
88 | // },
89 | // nil,
90 | // ),
91 | r.CreateElement(
92 | components.ClickableBox,
93 | r.Properties{
94 | box.Properties{
95 | Grow: 1,
96 | Foreground: tcell.ColorLawnGreen,
97 | Border: box.Border{
98 | Style: box.BorderStyleSingle,
99 | Foreground: tcell.ColorWhite,
100 | },
101 | Title: box.Label{
102 | Value: "Top",
103 | },
104 | },
105 | },
106 | nil,
107 | ),
108 | r.CreateElement(
109 | components.EffectExampleBox,
110 | r.Properties{
111 | box.Properties{
112 | Grow: 1,
113 | Foreground: tcell.ColorLightCyan,
114 | Border: box.Border{
115 | Style: box.BorderStyleSingle,
116 | Foreground: tcell.ColorWhite,
117 | },
118 | Title: box.Label{
119 | Value: "Bottom",
120 | },
121 | },
122 | },
123 | nil,
124 | ),
125 | },
126 | // nil,
127 | ),
128 | r.RetortConfiguration{},
129 | )
130 | }
131 |
--------------------------------------------------------------------------------
/example/components/ClickableBox.go:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import (
4 | "github.com/gdamore/tcell"
5 | "retort.dev/components/box"
6 | "retort.dev/r"
7 | )
8 |
9 | type MovingBoxState struct {
10 | Color tcell.Color
11 | }
12 |
13 | func ClickableBox(p r.Properties) r.Element {
14 | boxProps := p.GetProperty(
15 | box.Properties{},
16 | "Container requires ContainerProps",
17 | ).(box.Properties)
18 |
19 | children := p.GetProperty(
20 | r.Children{},
21 | "Container requires r.Children",
22 | ).(r.Children)
23 |
24 | s, setState := r.UseState(r.State{
25 | MovingBoxState{Color: boxProps.Border.Foreground},
26 | })
27 | state := s.GetState(
28 | MovingBoxState{},
29 | ).(MovingBoxState)
30 |
31 | mouseEventHandler := func(
32 | isPrimary,
33 | isSecondary bool,
34 | buttonMask tcell.ButtonMask,
35 | ) r.EventMouseClickRelease {
36 | color := tcell.ColorGreen
37 | if state.Color == tcell.ColorGreen {
38 | color = tcell.ColorBlue
39 | }
40 |
41 | if state.Color == tcell.ColorBlue {
42 | color = tcell.ColorGreen
43 | }
44 |
45 | setState(func(s r.State) r.State {
46 | return r.State{MovingBoxState{
47 | Color: color,
48 | },
49 | }
50 | })
51 | return func() {}
52 | }
53 |
54 | boxProps.Border.Foreground = state.Color
55 |
56 | return r.CreateElement(
57 | box.Box,
58 | r.Properties{
59 | boxProps,
60 | mouseEventHandler,
61 | },
62 | children,
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/example/components/EffectExampleBox.go:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/gdamore/tcell"
7 | "retort.dev/components/box"
8 | "retort.dev/r"
9 | )
10 |
11 | type EffectExampleBoxState struct {
12 | Color tcell.Color
13 | }
14 |
15 | func EffectExampleBox(p r.Properties) r.Element {
16 | boxProps := p.GetProperty(
17 | box.Properties{},
18 | "EffectExampleBox requires ContainerProps",
19 | ).(box.Properties)
20 |
21 | children := p.GetProperty(
22 | r.Children{},
23 | "EffectExampleBox requires r.Children",
24 | ).(r.Children)
25 |
26 | s, setState := r.UseState(r.State{
27 | EffectExampleBoxState{Color: boxProps.Border.Foreground},
28 | })
29 | state := s.GetState(
30 | EffectExampleBoxState{},
31 | ).(EffectExampleBoxState)
32 |
33 | r.UseEffect(func() r.EffectCancel {
34 | ticker := time.NewTicker(2 * time.Second)
35 |
36 | done := make(chan bool)
37 |
38 | go func() {
39 | for {
40 | select {
41 | case <-done:
42 | return
43 | case <-ticker.C:
44 | setState(func(s r.State) r.State {
45 | ms := s.GetState(
46 | EffectExampleBoxState{},
47 | ).(EffectExampleBoxState)
48 |
49 | color := tcell.ColorGreen
50 | if ms.Color == tcell.ColorGreen {
51 | color = tcell.ColorBlue
52 | }
53 |
54 | if ms.Color == tcell.ColorBlue {
55 | color = tcell.ColorGreen
56 | }
57 |
58 | return r.State{EffectExampleBoxState{
59 | Color: color,
60 | },
61 | }
62 | })
63 | }
64 | }
65 | }()
66 | return func() {
67 | <-done
68 | }
69 | }, r.EffectDependencies{})
70 |
71 | mouseEventHandler := func(e *tcell.EventMouse) {
72 | color := tcell.ColorGreen
73 | if state.Color == tcell.ColorGreen {
74 | color = tcell.ColorBlue
75 | }
76 |
77 | if state.Color == tcell.ColorBlue {
78 | color = tcell.ColorGreen
79 | }
80 |
81 | setState(func(s r.State) r.State {
82 | return r.State{EffectExampleBoxState{
83 | Color: color,
84 | },
85 | }
86 | })
87 | }
88 |
89 | boxProps.Border.Foreground = state.Color
90 |
91 | return r.CreateElement(
92 | box.Box,
93 | r.Properties{
94 | boxProps,
95 | mouseEventHandler,
96 | },
97 | children,
98 | )
99 | }
100 |
--------------------------------------------------------------------------------
/example/doc.go:
--------------------------------------------------------------------------------
1 | // package example shows how a retort app can be made
2 | package example // import "retort.dev/example"
3 |
--------------------------------------------------------------------------------
/example/hackernews/components/app/app.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "retort.dev/components/box"
5 | "retort.dev/r"
6 |
7 | "retort.dev/example/hackernews/components/cache"
8 | "retort.dev/example/hackernews/components/menu"
9 | "retort.dev/example/hackernews/components/story"
10 | "retort.dev/example/hackernews/components/theme"
11 | )
12 |
13 | type State struct {
14 | Color theme.Color
15 | }
16 |
17 | var defaultState = State{Color: theme.Orange}
18 |
19 | func App(p r.Properties) r.Element {
20 |
21 | s, setState := r.UseState(r.State{defaultState})
22 |
23 | state := s.GetState(
24 | defaultState,
25 | ).(State)
26 |
27 | setTheme := func(t theme.Color) {
28 | setState(func(s r.State) r.State {
29 | return r.State{
30 | State{
31 | Color: t,
32 | },
33 | }
34 | })
35 | }
36 |
37 | return r.CreateElement(
38 | theme.Theme,
39 | r.Properties{
40 | theme.Properties{
41 | Color: state.Color,
42 | },
43 | },
44 | r.Children{
45 | r.CreateElement(
46 | cache.Cache,
47 | r.Properties{},
48 | r.Children{
49 | // Wrapper
50 | r.CreateElement(
51 | box.Box,
52 | r.Properties{
53 | box.Properties{
54 | Width: 100,
55 | Height: 100,
56 | },
57 | },
58 | r.Children{
59 | // Menu
60 | r.CreateElement(
61 | menu.Menu,
62 | r.Properties{
63 | box.Properties{
64 | Grow: 1,
65 | },
66 | menu.Properties{
67 | SetTheme: setTheme,
68 | },
69 | },
70 | nil,
71 | ),
72 | // Story view
73 | r.CreateElement(
74 | story.Story,
75 | r.Properties{
76 | box.Properties{
77 | Grow: 3,
78 | },
79 | },
80 | nil,
81 | ),
82 | },
83 | ),
84 | },
85 | ),
86 | },
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/example/hackernews/components/cache/cache.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/munrocape/hn/hnclient"
7 | "retort.dev/r"
8 | )
9 |
10 | var StoriesContext = r.CreateContext(r.State{Stories{}})
11 | var CommentContext = r.CreateContext(r.State{Comments{}})
12 |
13 | // [ Stories ]------------------------------------------------------------------
14 |
15 | type StoryItem struct {
16 | Story *hnclient.Story
17 | Loading bool
18 | Error error
19 | LastUpdated time.Time
20 | }
21 | type Stories struct {
22 | Cache map[int]StoryItem
23 | Update func(id int, item StoryItem)
24 | }
25 |
26 | func (c *Stories) Get(id int) StoryItem {
27 | story, ok := c.Cache[id]
28 | if !ok {
29 | return StoryItem{}
30 | }
31 | return story
32 | }
33 |
34 | // [ Comments ]-----------------------------------------------------------------
35 |
36 | type CommentItem struct {
37 | Comment *hnclient.Comment
38 | Loading bool
39 | Error error
40 | LastUpdated time.Time
41 | }
42 | type Comments struct {
43 | Cache map[int]CommentItem
44 | Update func(id int, item CommentItem)
45 | }
46 |
47 | func Cache(p r.Properties) r.Element {
48 | children := p.GetProperty(
49 | r.Children{},
50 | "Cache requires r.Children",
51 | ).(r.Children)
52 |
53 | s, storySetState := r.UseState(r.State{
54 | Stories{
55 | Cache: make(map[int]StoryItem),
56 | },
57 | })
58 |
59 | storyState := s.GetState(
60 | Stories{},
61 | ).(Stories)
62 |
63 | storyState.Update = func(id int, item StoryItem) {
64 | storySetState(func(s r.State) r.State {
65 | storyCache := s.GetState(
66 | Stories{},
67 | ).(Stories)
68 | storyCache.Cache[id] = item
69 | return r.State{storyCache}
70 | })
71 | }
72 |
73 | StoriesContext.Mount(r.State{storyState})
74 |
75 | // storySetState(func(s r.State) r.State {
76 | // return r.State{
77 | // Stories{
78 | // Cache: make(map[int]StoryItem),
79 | // Update: updateStoryItem,
80 | // },
81 | // }
82 | // })
83 |
84 | // commentSetState := CommentContext.Mount()
85 | // updateCommentItem := func(id int, item CommentItem) {
86 | // commentSetState(func(s r.State) r.State {
87 | // s[id] = item
88 | // return r.State{s}
89 | // })
90 | // }
91 |
92 | // commentSetState(func(s r.State) r.State {
93 | // return r.State{
94 | // Comments{
95 | // Cache: make(map[int]CommentItem),
96 | // Update: updateCommentItem,
97 | // },
98 | // }
99 | // })
100 |
101 | return r.CreateFragment(children)
102 | }
103 |
--------------------------------------------------------------------------------
/example/hackernews/components/common/hooks/hn/client.go:
--------------------------------------------------------------------------------
1 | package hn
2 |
3 | import (
4 | "github.com/munrocape/hn/hnclient"
5 | )
6 |
7 | func UseHackerNews() *hnclient.Client {
8 | c := hnclient.NewClient()
9 | return c
10 | }
11 |
--------------------------------------------------------------------------------
/example/hackernews/components/common/hooks/hn/story.go:
--------------------------------------------------------------------------------
1 | package hn
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/munrocape/hn/hnclient"
7 | "retort.dev/example/hackernews/components/cache"
8 | "retort.dev/r"
9 | )
10 |
11 | type CurrentStoryState struct {
12 | Data hnclient.Story
13 | Loading bool
14 | Error error
15 | }
16 |
17 | // UseCurrentStory
18 | func UseCurrentStory(
19 | storiesContext *r.Context,
20 | commentsContext *r.Context,
21 | ) (
22 | CurrentStoryState,
23 | r.SetState,
24 | ) {
25 | s, setState := r.UseState(r.State{
26 | CurrentStoryState{},
27 | })
28 | state := s.GetState(
29 | CurrentStoryState{},
30 | ).(CurrentStoryState)
31 |
32 | return state, setState
33 | }
34 |
35 | func UseStory(id int) (
36 | story *hnclient.Story,
37 | loading bool,
38 | err error,
39 | ) {
40 | c := UseHackerNews()
41 |
42 | // Get our storyCache from the passed in Context
43 | sc := r.UseContext(cache.StoriesContext)
44 |
45 | storyCache := sc.GetState(
46 | cache.Stories{},
47 | ).(cache.Stories)
48 |
49 | if storyCache.Update == nil {
50 | return
51 | }
52 |
53 | r.UseEffect(func() r.EffectCancel {
54 | var story *hnclient.Story
55 | var needToFetch bool
56 |
57 | // Check if story is in the cache
58 | cachedStory, ok := storyCache.Cache[id]
59 | if !ok {
60 | needToFetch = true
61 | }
62 |
63 | // if it's in the cache, check how fresh it is
64 | if ok &&
65 | cachedStory.Story != nil &&
66 | time.Since(cachedStory.LastUpdated) > CacheTimeout {
67 | needToFetch = true
68 | }
69 |
70 | if cachedStory.Loading {
71 | needToFetch = false
72 | }
73 |
74 | if cachedStory.Story == nil {
75 | needToFetch = true
76 | }
77 | // debug.Spew("needToFetch", needToFetch, cachedStory, ok)
78 |
79 | // If we need to update, go fetch it
80 | if needToFetch {
81 | item := cache.StoryItem{
82 | Story: nil,
83 | Loading: true,
84 | LastUpdated: time.Now(),
85 | }
86 | storyCache.Update(id, item)
87 | rawStory, err := c.GetStory(id)
88 | story = &rawStory
89 |
90 | // Update the storyCache with our hydrated story
91 | loadedItem := cache.StoryItem{
92 | Story: story,
93 | Loading: false,
94 | Error: err,
95 | LastUpdated: time.Now(),
96 | }
97 | storyCache.Update(id, loadedItem)
98 | }
99 |
100 | return func() {}
101 | }, r.EffectDependencies{id})
102 |
103 | cachedStory, ok := storyCache.Cache[id]
104 |
105 | if !ok {
106 | return nil, true, nil
107 | }
108 |
109 | // TODO: return func to make this story the selected one
110 | return cachedStory.Story, cachedStory.Loading, nil
111 | }
112 |
--------------------------------------------------------------------------------
/example/hackernews/components/common/hooks/hn/top.go:
--------------------------------------------------------------------------------
1 | package hn
2 |
3 | import (
4 | "time"
5 |
6 | "retort.dev/example/hackernews/components/cache"
7 | "retort.dev/r"
8 | )
9 |
10 | var CacheTimeout time.Duration = time.Minute
11 |
12 | type TopStoriesState struct {
13 | Data []int
14 | Loading bool
15 | Error error
16 | }
17 |
18 | var checked bool
19 |
20 | // UseTopStories
21 | func UseTopStories() TopStoriesState {
22 | c := UseHackerNews()
23 |
24 | sc := r.UseContext(cache.StoriesContext)
25 | storyCache := sc.GetState(
26 | cache.Stories{},
27 | ).(cache.Stories)
28 |
29 | s, setState := r.UseState(r.State{
30 | TopStoriesState{Loading: true},
31 | })
32 | state := s.GetState(
33 | TopStoriesState{Loading: true},
34 | ).(TopStoriesState)
35 |
36 | // debug.Spew(state)
37 | // Update list of Top Stories
38 | r.UseEffect(func() r.EffectCancel {
39 | if storyCache.Update == nil {
40 | return func() {}
41 | }
42 |
43 | if checked {
44 | return func() {}
45 | }
46 |
47 | checked = true
48 |
49 | topStories, err := c.GetTopStories(10)
50 |
51 | if err != nil {
52 | // debug.Spew("topStories err", err)
53 | setState(func(s r.State) r.State {
54 | return r.State{TopStoriesState{
55 | Loading: false,
56 | Error: err,
57 | }}
58 | })
59 | } else {
60 | // debug.Spew("topStories", topStories)
61 | setState(func(s r.State) r.State {
62 | return r.State{TopStoriesState{
63 | Data: topStories,
64 | Loading: false,
65 | Error: nil,
66 | }}
67 | })
68 | }
69 |
70 | for _, id := range topStories {
71 | item := cache.StoryItem{
72 | Story: nil,
73 | Loading: false,
74 | LastUpdated: time.Now(),
75 | }
76 | storyCache.Update(id, item)
77 | }
78 |
79 | return func() {}
80 | }, r.EffectDependencies{storyCache.Update})
81 |
82 | // storiesContext := r.UseContext(cache.StoriesContext)
83 |
84 | // // Hydrate stories into cache
85 | // r.UseEffect(func() r.EffectCancel {
86 | // HydrateStories(state.Data, storiesContext)
87 |
88 | // return func() {}
89 | // }, r.EffectDependencies{state.Data})
90 |
91 | return state
92 | }
93 |
--------------------------------------------------------------------------------
/example/hackernews/components/menu/item.go:
--------------------------------------------------------------------------------
1 | package menu
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/gdamore/tcell"
7 | "retort.dev/components/box"
8 | "retort.dev/components/text"
9 | "retort.dev/example/hackernews/components/common/hooks/hn"
10 | "retort.dev/example/hackernews/components/theme"
11 | "retort.dev/r"
12 | )
13 |
14 | type MenuItemProps struct {
15 | Id int
16 | }
17 |
18 | func MenuItem(p r.Properties) r.Element {
19 | props := p.GetProperty(
20 | MenuItemProps{},
21 | "MenuItem requires MenuItemProps",
22 | ).(MenuItemProps)
23 |
24 | story, loading, err := hn.UseStory(props.Id)
25 |
26 | tc := r.UseContext(theme.Context)
27 |
28 | t := tc.GetState(
29 | theme.Colors{},
30 | ).(theme.Colors)
31 |
32 | boxProps := box.Properties{
33 | Margin: box.Margin{
34 | Bottom: 1,
35 | },
36 | Padding: box.Padding{
37 | Left: 1,
38 | Right: 1,
39 | },
40 | }
41 | // debug.Log("menu item loading ", loading, props.Id)
42 |
43 | // onClick := func(
44 | // isPrimary,
45 | // isSecondary bool,
46 | // buttonMask tcell.ButtonMask,
47 | // ) r.EventMouseClickRelease {
48 | // if isPrimary {
49 | // props.SetTheme(theme.White)
50 | // }
51 | // return func() {}
52 | // }
53 |
54 | if loading {
55 | return r.CreateElement(
56 | text.Text,
57 | r.Properties{
58 | boxProps,
59 | text.Properties{
60 | Value: "Loading",
61 | Foreground: t.Subtle,
62 | },
63 | },
64 | nil,
65 | )
66 | }
67 |
68 | if err != nil {
69 | // debug.Log("menu item err", err)
70 | return r.CreateElement(
71 | text.Text,
72 | r.Properties{
73 | boxProps,
74 | text.Properties{
75 | Value: fmt.Sprintf("%s", err),
76 | Foreground: t.Foreground,
77 | },
78 | },
79 | nil,
80 | )
81 | }
82 |
83 | if story == nil {
84 | return nil
85 | }
86 | // return nil
87 | return r.CreateElement(
88 | box.Box,
89 | r.Properties{
90 | box.Properties{
91 | Direction: box.DirectionColumn,
92 | Grow: 1,
93 | Padding: box.Padding{
94 | Left: 1,
95 | Right: 1,
96 | },
97 | Border: box.Border{
98 | Foreground: tcell.ColorGray,
99 | Style: box.BorderStyleSingle,
100 | },
101 | },
102 | },
103 | r.Children{
104 | r.CreateElement(
105 | text.Text,
106 | r.Properties{
107 | box.Properties{Grow: 1},
108 | text.Properties{
109 | Value: story.Title,
110 | Foreground: t.Foreground,
111 | },
112 | },
113 | nil,
114 | ),
115 | r.CreateElement(
116 | text.Text,
117 | r.Properties{
118 | box.Properties{Grow: 1},
119 | text.Properties{
120 | Value: fmt.Sprintf(
121 | "Score: %d Comments: %d",
122 | story.Score,
123 | story.Descendants,
124 | ),
125 | Foreground: t.Subtle,
126 | },
127 | },
128 | nil,
129 | ),
130 | },
131 | )
132 | }
133 |
--------------------------------------------------------------------------------
/example/hackernews/components/menu/menu.go:
--------------------------------------------------------------------------------
1 | package menu
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/gdamore/tcell"
7 | "retort.dev/components/box"
8 | "retort.dev/example/hackernews/components/common/hooks/hn"
9 | "retort.dev/example/hackernews/components/theme"
10 | "retort.dev/r"
11 | )
12 |
13 | type Properties struct {
14 | SetTheme func(t theme.Color)
15 | }
16 |
17 | func Menu(p r.Properties) r.Element {
18 | title := "Top Stories"
19 | props := p.GetProperty(
20 | Properties{},
21 | "Menu requires menu.Properties",
22 | ).(Properties)
23 |
24 | tc := r.UseContext(theme.Context)
25 |
26 | t := tc.GetState(
27 | theme.Colors{},
28 | ).(theme.Colors)
29 |
30 | onClick := func(
31 | isPrimary,
32 | isSecondary bool,
33 | buttonMask tcell.ButtonMask,
34 | ) r.EventMouseClickRelease {
35 | if isPrimary {
36 | props.SetTheme(theme.White)
37 | }
38 | return func() {}
39 | }
40 |
41 | stories := hn.UseTopStories()
42 |
43 | // debug.Spew("stories", stories)
44 |
45 | var items r.Children
46 | if stories.Data != nil &&
47 | len(stories.Data) > 0 {
48 |
49 | for _, id := range stories.Data {
50 | items = append(items, r.CreateElement(
51 | MenuItem,
52 | r.Properties{
53 | MenuItemProps{
54 | Id: id,
55 | },
56 | },
57 | nil,
58 | ))
59 | }
60 | }
61 |
62 | if stories.Loading {
63 | title = fmt.Sprintf("%s %s", title, "[ Loading ]")
64 | }
65 |
66 | return r.CreateElement(
67 | box.Box,
68 | r.Properties{
69 | box.Properties{
70 | Foreground: t.Foreground,
71 | Border: box.Border{
72 | Style: box.BorderStyleSingle,
73 | Foreground: t.Border,
74 | },
75 | Title: box.Label{
76 | Value: title,
77 | },
78 | Direction: box.DirectionColumn,
79 | Footer: box.Label{
80 | Value: "Hacker News",
81 | Wrap: box.LabelWrapSquareBracket,
82 | },
83 | Overflow: box.OverflowScrollX,
84 | MinHeight: 5,
85 | },
86 | onClick,
87 | },
88 | items,
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/example/hackernews/components/story/story.go:
--------------------------------------------------------------------------------
1 | package story
2 |
3 | import (
4 | "retort.dev/components/box"
5 | "retort.dev/example/hackernews/components/theme"
6 | "retort.dev/r"
7 | )
8 |
9 | func Story(p r.Properties) r.Element {
10 |
11 | tc := r.UseContext(theme.Context)
12 |
13 | t := tc.GetState(
14 | theme.Colors{},
15 | ).(theme.Colors)
16 |
17 | return r.CreateElement(
18 | box.Box,
19 | r.Properties{
20 | box.Properties{
21 | Width: 100,
22 | Height: 100,
23 | Foreground: t.Foreground,
24 | Border: box.Border{
25 | Style: box.BorderStyleSingle,
26 | Foreground: t.Border,
27 | },
28 |
29 | Title: box.Label{
30 | Value: "Loading Story",
31 | },
32 | Overflow: box.OverflowScrollX,
33 | },
34 | },
35 | nil,
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/example/hackernews/components/theme/context.go:
--------------------------------------------------------------------------------
1 | package theme
2 |
3 | import (
4 | "retort.dev/r"
5 | )
6 |
7 | var Context = r.CreateContext(r.State{orange})
8 |
9 | type Properties struct {
10 | Color Color
11 | }
12 |
13 | func Theme(p r.Properties) r.Element {
14 | children := p.GetProperty(
15 | r.Children{},
16 | "Theme requires r.Children",
17 | ).(r.Children)
18 |
19 | props := p.GetProperty(
20 | Properties{
21 | Color: Orange,
22 | },
23 | "Theme requires Properties",
24 | ).(Properties)
25 |
26 | color := Colors{}
27 | switch props.Color {
28 | case Orange:
29 | color = orange
30 | case White:
31 | color = white
32 | }
33 |
34 | // TODO: double check this
35 | s, _ := r.UseState(r.State{color})
36 |
37 | state := s.GetState(
38 | Colors{},
39 | ).(Colors)
40 |
41 | Context.Mount(r.State{state})
42 |
43 | return r.CreateFragment(children)
44 | }
45 |
--------------------------------------------------------------------------------
/example/hackernews/components/theme/themes.go:
--------------------------------------------------------------------------------
1 | package theme
2 |
3 | import "github.com/gdamore/tcell"
4 |
5 | type Color int
6 |
7 | const (
8 | Orange Color = iota
9 | White
10 | )
11 |
12 | type Colors struct {
13 | Border tcell.Color
14 | Accent tcell.Color
15 | Foreground tcell.Color
16 | Subtle tcell.Color
17 | }
18 |
19 | var orange Colors = Colors{
20 | Border: tcell.ColorOrange,
21 | Accent: tcell.ColorOrange,
22 | Foreground: tcell.ColorWhite,
23 | Subtle: tcell.ColorGrey,
24 | }
25 |
26 | var white Colors = Colors{
27 | Border: tcell.ColorGrey,
28 | Accent: tcell.ColorWhite,
29 | Foreground: tcell.ColorWhite,
30 | Subtle: tcell.ColorGrey,
31 | }
32 |
--------------------------------------------------------------------------------
/example/hackernews/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "retort.dev/example/hackernews/components/app"
5 | "retort.dev/r"
6 | )
7 |
8 | // TODO: https://github.com/munrocape/hn
9 | func main() {
10 | r.Retort(
11 | r.CreateElement(
12 | app.App,
13 | nil,
14 | nil,
15 | ),
16 | r.RetortConfiguration{},
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/example_app_test.go:
--------------------------------------------------------------------------------
1 | package retort_test
2 |
3 | import (
4 | // import tcell to get access to colors and event types
5 | "github.com/gdamore/tcell"
6 |
7 | "retort.dev/components/box"
8 | example "retort.dev/example/components"
9 | "retort.dev/r"
10 | )
11 |
12 | var exampleVarToMakeGoDocPrintTheWholeFile bool
13 |
14 | func Example_app() {
15 | // Call the main function on retort to start the app,
16 | // when you call this, retort will take over the screen.
17 | r.Retort(
18 | // Root Element
19 | r.CreateElement(
20 | example.ClickableBox,
21 | r.Properties{
22 | box.Properties{
23 | Width: 100, // Make the root element fill the screen
24 | Height: 100, // Make the root element fill the screen
25 | Border: box.Border{
26 | Style: box.BorderStyleSingle,
27 | Foreground: tcell.ColorWhite,
28 | },
29 | },
30 | },
31 | r.Children{
32 | // First Child
33 | r.CreateElement(
34 | example.ClickableBox,
35 | r.Properties{
36 | box.Properties{
37 | Border: box.Border{
38 | Style: box.BorderStyleSingle,
39 | Foreground: tcell.ColorWhite,
40 | },
41 | },
42 | },
43 | nil, // Pass nil as the third argument if there are no children
44 | ),
45 | // Second Child
46 | r.CreateElement(
47 | example.ClickableBox,
48 | r.Properties{
49 | box.Properties{
50 | Border: box.Border{
51 | Style: box.BorderStyleSingle,
52 | Foreground: tcell.ColorWhite,
53 | },
54 | },
55 | },
56 | nil,
57 | ),
58 | },
59 | ),
60 | // Pass in optional configuration
61 | r.RetortConfiguration{},
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module retort.dev
2 |
3 | go 1.13
4 |
5 | require (
6 | github.com/davecgh/go-spew v1.1.1
7 | github.com/gdamore/tcell v1.3.0
8 | github.com/mattn/go-runewidth v0.0.7
9 | github.com/munrocape/hn v0.0.0-20150319232634-8a8a24c2c8db
10 | )
11 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/DATA-DOG/go-sqlmock v1.3.3 h1:CWUqKXe0s8A2z6qCgkP4Kru7wC11YoAnoupUKFDnH08=
2 | github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
6 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
7 | github.com/gdamore/tcell v1.3.0 h1:r35w0JBADPZCVQijYebl6YMWWtHRqVEGt7kL2eBADRM=
8 | github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
9 | github.com/lucasb-eyer/go-colorful v1.0.2 h1:mCMFu6PgSozg9tDNMMK3g18oJBX7oYGrC09mS6CXfO4=
10 | github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
11 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
12 | github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=
13 | github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
14 | github.com/munrocape/hn v0.0.0-20150319232634-8a8a24c2c8db h1:3/qtKpfgo4nf30lpbHU/ZU6IiFr8I5DPOdSBosYM0Gk=
15 | github.com/munrocape/hn v0.0.0-20150319232634-8a8a24c2c8db/go.mod h1:AkANiVk3x4Zx5lVDTkrU3QoZagMKOf3Yhz6ALcDxvr0=
16 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10=
17 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
18 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
19 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
20 |
--------------------------------------------------------------------------------
/gopher.svg:
--------------------------------------------------------------------------------
1 |
2 |
60 |
--------------------------------------------------------------------------------
/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ojkelly/retort/f9a6796805a8e8104127ef61f634114917ec9b5d/icon.ico
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Retort | A reactive terminal user interface (tui) framework for golang
21 |
22 |
23 |
24 |
25 |
51 |
52 |
53 |
60 |
61 |
67 |
74 |
75 |
76 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ojkelly/retort/f9a6796805a8e8104127ef61f634114917ec9b5d/logo.png
--------------------------------------------------------------------------------
/makefile:
--------------------------------------------------------------------------------
1 |
2 | # Watch for source changes and reload
3 | watch:
4 | watchman-make -p '**/*.go' -t dev
5 |
6 | dev:
7 | rm debug.log || true
8 | go run example/hackernews/main.go
9 |
10 | test:
11 | go test ./...
12 |
13 | # Build the example app
14 | demo:
15 | rm debug.log || true
16 | go run example/cmd/main.go
17 |
18 |
19 | # Build the hn app
20 | hn:
21 | rm debug.log || true
22 | go run example/hackernews/main.go
23 |
24 |
25 | # Run the example app with the race detector
26 | race:
27 | rm debug.log || true
28 | go run -race example/cmd/main.go 2>&1 | tee race.log
29 |
30 | DOCS_PORT=6060
31 | docs:
32 | godoc -http=":$(DOCS_PORT)" & open http://localhost:$(DOCS_PORT)/pkg/retort.dev/
33 |
34 | # Serve the retort.dev website for development
35 | # requires npm and npx on the system
36 | # its just a static page, load it in a browser preview if you want
37 | serve:
38 | npx serve .
39 |
40 | # Install watchman on macOS
41 | # also upgrade pywatchman, to one that works with python3
42 | # https://github.com/facebook/watchman/issues/631#issuecomment-541255161
43 | install-watchman-macos:
44 | pip install pywatchman
45 | brew install watchman
46 |
47 | prepare-site:
48 | find ./** -type d -exec cp redirect.html {}/index.html \;
49 |
50 | remove-redirect-html:
51 | rm -rf ./**/index.html
52 |
--------------------------------------------------------------------------------
/mvp.css:
--------------------------------------------------------------------------------
1 | /* MVP.css v1.6.2 - https://github.com/andybrewer/mvp */
2 |
3 | :root {
4 | --border-radius: 4px;
5 | --box-shadow: 2px 2px 10px;
6 | --color: #c82829;
7 | --color-accent: #efefef;
8 | --color-bg: #fff;
9 | --color-bg-secondary: #d6d6d6;
10 | --color-secondary: #4271ae;
11 | --color-secondary-accent: #d6d6d6;
12 | --color-shadow: #efefef;
13 | --color-text: #4d4d4c;
14 | --color-text-secondary: #8e908c;
15 | --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
16 | Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
17 | --hover-brightness: 1.2;
18 | --justify-important: center;
19 | --justify-normal: left;
20 | --line-height: 1.5;
21 | --width-card: 285px;
22 | --width-card-medium: 460px;
23 | --width-card-wide: 800px;
24 | --width-content: 1080px;
25 | }
26 |
27 | @media (prefers-color-scheme: dark) {
28 | :root {
29 | --color: #cc6666;
30 | --color-accent: #282a2e;
31 | --color-bg: #1d1f21;
32 | --color-bg-secondary: #373b41;
33 | --color-secondary: #81a2be;
34 | --color-secondary-accent: #373b41;
35 | --color-shadow: #282a2e;
36 | --color-text: #c5c8c6;
37 | --color-text-secondary: #969896;
38 | }
39 | }
40 |
41 | /* Layout */
42 | article aside {
43 | background: var(--color-secondary-accent);
44 | border-left: 4px solid var(--color-secondary);
45 | padding: 0.01rem 0.8rem;
46 | }
47 |
48 | body {
49 | background: var(--color-bg);
50 | color: var(--color-text);
51 | font-family: var(--font-family);
52 | line-height: var(--line-height);
53 | margin: 0;
54 | overflow-x: hidden;
55 | padding: 0;
56 | }
57 |
58 | footer,
59 | header,
60 | main {
61 | margin: 0 auto;
62 | max-width: var(--width-content);
63 | padding: 1rem 1rem;
64 | }
65 |
66 | hr {
67 | background-color: var(--color-bg-secondary);
68 | border: none;
69 | height: 1px;
70 | margin: 4rem 0;
71 | }
72 |
73 | section {
74 | display: flex;
75 | flex-wrap: wrap;
76 | justify-content: var(--justify-important);
77 | }
78 |
79 | section aside {
80 | border: 1px solid var(--color-bg-secondary);
81 | border-radius: var(--border-radius);
82 | box-shadow: var(--box-shadow) var(--color-shadow);
83 | margin: 1rem;
84 | padding: 1.25rem;
85 | width: var(--width-card);
86 | }
87 |
88 | section aside:hover {
89 | box-shadow: var(--box-shadow) var(--color-bg-secondary);
90 | }
91 |
92 | section aside img {
93 | max-width: 100%;
94 | }
95 |
96 | [hidden] {
97 | display: none;
98 | }
99 |
100 | /* Headers */
101 | article header,
102 | div header,
103 | main header {
104 | padding-top: 0;
105 | }
106 |
107 | header {
108 | text-align: var(--justify-important);
109 | }
110 |
111 | header a b,
112 | header a em,
113 | header a i,
114 | header a strong {
115 | margin-left: 0.5rem;
116 | margin-right: 0.5rem;
117 | }
118 |
119 | header nav img {
120 | margin: 1rem 0;
121 | }
122 |
123 | section header {
124 | padding-top: 0;
125 | width: 100%;
126 | }
127 |
128 | /* Nav */
129 | nav {
130 | align-items: center;
131 | display: flex;
132 | font-weight: bold;
133 | justify-content: space-between;
134 | }
135 |
136 | nav ul {
137 | list-style: none;
138 | padding: 0;
139 | }
140 |
141 | nav ul li {
142 | display: inline-block;
143 | margin: 0 0.5rem;
144 | position: relative;
145 | text-align: left;
146 | }
147 |
148 | /* Nav Dropdown */
149 | nav ul li:hover ul {
150 | display: block;
151 | }
152 |
153 | nav ul li ul {
154 | background: var(--color-bg);
155 | border: 1px solid var(--color-bg-secondary);
156 | border-radius: var(--border-radius);
157 | box-shadow: var(--box-shadow) var(--color-shadow);
158 | display: none;
159 | height: auto;
160 | left: -2px;
161 | padding: 0.5rem 1rem;
162 | position: absolute;
163 | top: 1.7rem;
164 | white-space: nowrap;
165 | width: auto;
166 | }
167 |
168 | nav ul li ul li,
169 | nav ul li ul li a {
170 | display: block;
171 | }
172 |
173 | /* Typography */
174 | code,
175 | samp {
176 | background-color: var(--color-accent);
177 | border-radius: var(--border-radius);
178 | color: var(--color-text);
179 | display: inline-block;
180 | margin: 0 0.1rem;
181 | padding: 0 0.5rem;
182 | }
183 |
184 | details {
185 | margin: 1.3rem 0;
186 | }
187 |
188 | details summary {
189 | font-weight: bold;
190 | cursor: pointer;
191 | }
192 |
193 | h1,
194 | h2,
195 | h3,
196 | h4,
197 | h5,
198 | h6 {
199 | line-height: var(--line-height);
200 | }
201 |
202 | mark {
203 | padding: 0.1rem;
204 | }
205 |
206 | ol li,
207 | ul li {
208 | padding: 0.2rem 0;
209 | }
210 |
211 | p {
212 | margin: 0.75rem 0;
213 | padding: 0;
214 | }
215 |
216 | pre {
217 | margin: 1rem 0;
218 | max-width: var(--width-card-wide);
219 | padding: 1rem 0;
220 | }
221 |
222 | pre code,
223 | pre samp {
224 | display: block;
225 | max-width: var(--width-card-wide);
226 | padding: 0.5rem 2rem;
227 | white-space: pre-wrap;
228 | }
229 |
230 | small {
231 | color: var(--color-text-secondary);
232 | }
233 |
234 | sup {
235 | background-color: var(--color-secondary);
236 | border-radius: var(--border-radius);
237 | color: var(--color-bg);
238 | font-size: xx-small;
239 | font-weight: bold;
240 | margin: 0.2rem;
241 | padding: 0.2rem 0.3rem;
242 | position: relative;
243 | top: -2px;
244 | }
245 |
246 | /* Links */
247 | a {
248 | color: var(--color-secondary);
249 | display: inline-block;
250 | font-weight: bold;
251 | text-decoration: none;
252 | }
253 |
254 | a:hover {
255 | filter: brightness(var(--hover-brightness));
256 | text-decoration: underline;
257 | }
258 |
259 | a b,
260 | a em,
261 | a i,
262 | a strong,
263 | button {
264 | border-radius: var(--border-radius);
265 | display: inline-block;
266 | font-size: medium;
267 | font-weight: bold;
268 | line-height: var(--line-height);
269 | margin: 0.5rem 0;
270 | padding: 1rem 2rem;
271 | }
272 |
273 | button {
274 | font-family: var(--font-family);
275 | }
276 |
277 | button:hover {
278 | cursor: pointer;
279 | filter: brightness(var(--hover-brightness));
280 | }
281 |
282 | a b,
283 | a strong,
284 | button {
285 | background-color: var(--color);
286 | border: 2px solid var(--color);
287 | color: var(--color-bg);
288 | }
289 |
290 | a em,
291 | a i {
292 | border: 2px solid var(--color);
293 | border-radius: var(--border-radius);
294 | color: var(--color);
295 | display: inline-block;
296 | padding: 1rem 2rem;
297 | }
298 |
299 | /* Images */
300 | figure {
301 | margin: 0;
302 | padding: 0;
303 | }
304 |
305 | figure img {
306 | max-width: 100%;
307 | }
308 |
309 | figure figcaption {
310 | color: var(--color-text-secondary);
311 | }
312 |
313 | /* Forms */
314 |
315 | button:disabled,
316 | input:disabled {
317 | background: var(--color-bg-secondary);
318 | border-color: var(--color-bg-secondary);
319 | color: var(--color-text-secondary);
320 | cursor: not-allowed;
321 | }
322 |
323 | button[disabled]:hover {
324 | filter: none;
325 | }
326 |
327 | form {
328 | border: 1px solid var(--color-bg-secondary);
329 | border-radius: var(--border-radius);
330 | box-shadow: var(--box-shadow) var(--color-shadow);
331 | display: block;
332 | max-width: var(--width-card-wide);
333 | min-width: var(--width-card);
334 | padding: 1.5rem;
335 | text-align: var(--justify-normal);
336 | }
337 |
338 | form header {
339 | margin: 1.5rem 0;
340 | padding: 1.5rem 0;
341 | }
342 |
343 | input,
344 | label,
345 | select,
346 | textarea {
347 | display: block;
348 | font-size: inherit;
349 | max-width: var(--width-card-wide);
350 | }
351 |
352 | input[type="checkbox"],
353 | input[type="radio"] {
354 | display: inline-block;
355 | }
356 |
357 | input[type="checkbox"] + label,
358 | input[type="radio"] + label {
359 | display: inline-block;
360 | font-weight: normal;
361 | position: relative;
362 | top: 1px;
363 | }
364 |
365 | input,
366 | select,
367 | textarea {
368 | border: 1px solid var(--color-bg-secondary);
369 | border-radius: var(--border-radius);
370 | margin-bottom: 1rem;
371 | padding: 0.4rem 0.8rem;
372 | }
373 |
374 | input[readonly],
375 | textarea[readonly] {
376 | background-color: var(--color-bg-secondary);
377 | }
378 |
379 | label {
380 | font-weight: bold;
381 | margin-bottom: 0.2rem;
382 | }
383 |
384 | /* Tables */
385 | table {
386 | border: 1px solid var(--color-bg-secondary);
387 | border-radius: var(--border-radius);
388 | border-spacing: 0;
389 | display: inline-block;
390 | max-width: 100%;
391 | overflow-x: auto;
392 | padding: 0;
393 | white-space: nowrap;
394 | }
395 |
396 | table td,
397 | table th,
398 | table tr {
399 | padding: 0.4rem 0.8rem;
400 | text-align: var(--justify-important);
401 | }
402 |
403 | table thead {
404 | background-color: var(--color);
405 | border-collapse: collapse;
406 | border-radius: var(--border-radius);
407 | color: var(--color-bg);
408 | margin: 0;
409 | padding: 0;
410 | }
411 |
412 | table thead th:first-child {
413 | border-top-left-radius: var(--border-radius);
414 | }
415 |
416 | table thead th:last-child {
417 | border-top-right-radius: var(--border-radius);
418 | }
419 |
420 | table thead th:first-child,
421 | table tr td:first-child {
422 | text-align: var(--justify-normal);
423 | }
424 |
425 | table tr:nth-child(even) {
426 | background-color: var(--color-accent);
427 | }
428 |
429 | /* Quotes */
430 | blockquote {
431 | display: block;
432 | font-size: x-large;
433 | line-height: var(--line-height);
434 | margin: 1rem auto;
435 | max-width: var(--width-card-medium);
436 | padding: 1.5rem 1rem;
437 | text-align: var(--justify-important);
438 | }
439 |
440 | blockquote footer {
441 | color: var(--color-text-secondary);
442 | display: block;
443 | font-size: small;
444 | line-height: var(--line-height);
445 | padding: 1.5rem 0;
446 | }
447 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | command = "make prepare-site"
3 |
--------------------------------------------------------------------------------
/r/debug/debug.go:
--------------------------------------------------------------------------------
1 | package debug // import "retort.dev/r/debug"
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "sync"
7 |
8 | "github.com/davecgh/go-spew/spew"
9 | )
10 |
11 | const debugLogPath = "debug.log"
12 | const debugLineBreak = "\n--------------------------------------------------------------------------------\n"
13 |
14 | var debugMutex = &sync.Mutex{}
15 | var debugFile *os.File
16 |
17 | func init() {
18 | f, err := os.OpenFile(debugLogPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
19 |
20 | if err != nil {
21 | panic(f)
22 | }
23 | debugFile = f
24 | // defer f.Close()
25 | }
26 |
27 | func Log(message ...interface{}) {
28 | debugMutex.Lock()
29 | debugFile.WriteString(debugLineBreak)
30 |
31 | debugFile.WriteString(fmt.Sprint(message...))
32 |
33 | debugMutex.Unlock()
34 | }
35 |
36 | func Spew(message ...interface{}) {
37 | debugMutex.Lock()
38 | debugFile.WriteString(debugLineBreak)
39 |
40 | spew.Fdump(debugFile, message...)
41 | debugMutex.Unlock()
42 | }
43 |
--------------------------------------------------------------------------------
/r/doc.go:
--------------------------------------------------------------------------------
1 | /* package r is the core retort package
2 |
3 | TODO: Explain how this all works.
4 |
5 | TODO: link to the relevant files and what they do.
6 | */
7 | package r // import "retort.dev/r"
8 |
--------------------------------------------------------------------------------
/r/element.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "reflect"
5 | )
6 |
7 | type (
8 | // Element is the smallest building block of retort.
9 | // You create them with r.CreateElement or r.CreateFragment
10 | //
11 | // Internally they are a pointer to a fiber, which is used
12 | // to keep track of the render tree.
13 | Element *fiber
14 |
15 | // Component is main thing you will be making to create a
16 | // retort app. Your component must match this function
17 | // signature.
18 | Component func(props Properties) Element
19 |
20 | // Children is a slice of Elements (or pointers to a fiber)
21 | // It's used in r.CreateElement or r.CreateFragment to
22 | // specify the child nodes of the Element.
23 | // It can also be extracted from props with GetProperty or
24 | // GetOptionalProperty, if you want to pass children on.
25 | //
26 | // In general, unless you're creating a ScreenElement,
27 | // you should pass any children passed into props
28 | // on to the return Element.
29 | Children []*fiber
30 |
31 | // Properties are immutable state that is passed into a component, and pass
32 | // down to components to share data.
33 | //
34 | // Properties is ultimately a slice of interfaces, which lets you and retort
35 | // and any components your using add any concrete structs to it. Because of
36 | // this, there are some helper methods to retrieve props. These are
37 | // GetProperty and GetOptionalProperty.
38 | //
39 | // Properties can only contain one struct of a given type. In this sense the
40 | // type of the struct is a key.
41 | //
42 | // Sometimes called props.
43 | Properties []interface{}
44 |
45 | // State is local to a component.
46 | // It is mutable via the setState function from UseState. Don't edit State
47 | // directly, as retort will not know that you have, and will not trigger an
48 | // update and re-render.
49 | // It can be used to create new props to pass down to other components.
50 | State []interface{}
51 | )
52 |
53 | // CreateElement is used to create the building blocks of a retort application,
54 | // and the thing that Components are ultimately made up of, Elements.
55 | //
56 | // import (
57 | // "github.com/gdamore/tcell"
58 | // "retort.dev/r"
59 | // "retort.dev/r/component"
60 | // )
61 | //
62 | // // Wrapper is a simple component that wraps the
63 | // // children Components in a box with a white border.
64 | // func Wrapper(p r.Properties) r.Element {
65 | // children := r.GetProperty(
66 | // p,
67 | // r.Children{},
68 | // "Container requires r.Children",
69 | // ).(r.Children)
70 | //
71 | // return r.CreateElement(
72 | // component.Box,
73 | // r.Properties{
74 | // component.BoxProps{
75 | // Border: component.Border{
76 | // Style: component.BorderStyleSingle,
77 | // Foreground: tcell.ColorWhite,
78 | // },
79 | // },
80 | // },
81 | // children,
82 | // )
83 | // }
84 | //
85 | // By creating an Element and passing Properties and Children seperately, retort
86 | // can keep track of the entire tree of Components, and decide when to compute
87 | // which parts, and in turn when to render those to the screen.
88 | func CreateElement(
89 | component Component,
90 | props Properties,
91 | children Children,
92 | ) *fiber {
93 | // debug.Log("CreateElement", component, props, children)
94 | if !checkPropTypesAreUnique(props) {
95 | panic("props are not unique")
96 | }
97 | return &fiber{
98 | componentType: elementComponent,
99 | component: component,
100 | Properties: append(
101 | props,
102 | children,
103 | ),
104 | }
105 | }
106 |
107 | // CreateFragment is like CreateElement except you do not need a Component
108 | // or Properties. This is useful when you need to make Higher Order Components,
109 | // or other Components that wrap or compose yet more Components.
110 | func CreateFragment(children Children) *fiber {
111 | return &fiber{
112 | componentType: fragmentComponent,
113 | component: nil,
114 | Properties: Properties{
115 | children,
116 | },
117 | }
118 | }
119 |
120 | // CreateScreenElement is like a normal Element except it has the
121 | // ability to render output to the screen.
122 | //
123 | // Once retort has finished calculating which components have changed
124 | // all those with changes are passed to a render function.
125 | // This walks the tree and finds ScreenElements and calls their
126 | // RenderToScreen function, passing in the current Screen.
127 | //
128 | // RenderToScreen needs to return a BlockLayout, which is used among
129 | // other things to direct Mouse Events to the right Component.
130 | //
131 | // func Box(p r.Properties) r.Element {
132 | // return r.CreateScreenElement(
133 | // func(s tcell.Screen) r.BlockLayout {
134 | // return BlockLayout
135 | // },
136 | // nil,
137 | // )
138 | // }
139 | func CreateScreenElement(
140 | calculateLayout CalculateLayout,
141 | render RenderToScreen,
142 | props Properties,
143 | children Children,
144 | ) *fiber {
145 | return &fiber{
146 | componentType: screenComponent,
147 | calculateLayout: &calculateLayout,
148 | renderToScreen: &render,
149 | Properties: append(
150 | props,
151 | children,
152 | ),
153 | component: nil,
154 | }
155 | }
156 |
157 | func checkPropTypesAreUnique(props Properties) bool {
158 | seenPropTypes := make(map[reflect.Type]bool)
159 |
160 | for _, p := range props {
161 | if seen := seenPropTypes[reflect.TypeOf(p)]; seen {
162 | return false
163 | }
164 | seenPropTypes[reflect.TypeOf(p)] = true
165 | }
166 | return true
167 | }
168 |
169 | // GetProperty will search props for the Property matching the type of the
170 | // struct you passed in, and will throw an Error with the message provided
171 | // if not found.
172 | //
173 | // This is useful when your component will not work without the provided
174 | // Property. However it is very unforgiving, and generally you will want to use
175 | // GetOptionalProperty which allows you to provide a default Property to use.
176 | //
177 | // Because this uses reflection, you must pass in a concrete struct not just the
178 | // type. For example r.Children is the type but r.Children{} is a struct of that
179 | // type. Only the latter will work.
180 | //
181 | // func Wrapper(p r.Properties) r.Element {
182 | // children := p.GetProperty(
183 | // r.Children{},
184 | // "Container requires r.Children",
185 | // ).(r.Children)
186 | //
187 | // return r.CreateElement(
188 | // component.Box,
189 | // r.Properties{
190 | // component.BoxProps{
191 |
192 | // Border: component.Border{
193 | // Style: component.BorderStyleSingle,
194 | // Foreground: tcell.ColorWhite,
195 | // },
196 | // },
197 | // },
198 | // children,
199 | // )
200 | // }
201 | func (props Properties) GetProperty(
202 | propType interface{},
203 | errorMessage string,
204 | ) interface{} {
205 | for _, p := range props {
206 | if reflect.TypeOf(p) == reflect.TypeOf(propType) {
207 | return p
208 | }
209 | }
210 | // debug.Spew(props)
211 | panic(errorMessage)
212 | }
213 |
214 | // GetOptionalProperty will search props for the Property matching the type of
215 | // struct you passed in. If it was not in props, the struct passed into propType
216 | // will be returned.
217 | //
218 | // You need to cast the return type of the function exactly the same as the
219 | // struct you pass in.
220 | //
221 | // This allows you to specify a defaults for a property.
222 | //
223 | // In the following example if Wrapper is not passed a Property of the type
224 | // component.BoxProps, the default values provided will be used.
225 | //
226 | // func Wrapper(p r.Properties) r.Element {
227 | // boxProps := p.GetOptionalProperty(
228 | // component.BoxProps{
229 | // Border: component.Border{
230 | // Style: component.BorderStyleSingle,
231 | // Foreground: tcell.ColorWhite,
232 | // },
233 | // },
234 | // ).(component.BoxProps)
235 | //
236 | // return r.CreateElement(
237 | // component.Box,
238 | // r.Properties{
239 | // boxProps
240 | // },
241 | // children,
242 | // )
243 | // }
244 | func (props Properties) GetOptionalProperty(
245 | propType interface{},
246 | ) interface{} {
247 | for _, p := range props {
248 | if reflect.TypeOf(p) == reflect.TypeOf(propType) {
249 | return p
250 | }
251 | }
252 | return propType
253 | }
254 |
255 | // filterProps returns all props except the type you pass.
256 | func filterProps(props Properties, prop interface{}) Properties {
257 | newProps := Properties{}
258 |
259 | for _, p := range props {
260 | if reflect.TypeOf(p) != reflect.TypeOf(prop) {
261 | newProps = append(newProps, p)
262 | }
263 | }
264 | return newProps
265 | }
266 |
267 | // ReplaceProps by replacing with the same type you passed.
268 | func ReplaceProps(props Properties, prop interface{}) Properties {
269 | newProps := Properties{prop}
270 |
271 | for _, p := range props {
272 | if reflect.TypeOf(p) != reflect.TypeOf(prop) {
273 | newProps = append(newProps, p)
274 | }
275 | }
276 | return newProps
277 | }
278 |
279 | // AddPropsIfNone will add the prop to props is no existing prop of that type
280 | // is found.
281 | func AddPropsIfNone(props Properties, prop interface{}) Properties {
282 | foundProp := false
283 |
284 | for _, p := range props {
285 | if reflect.TypeOf(p) == reflect.TypeOf(prop) {
286 | foundProp = true
287 | }
288 | }
289 |
290 | if !foundProp {
291 | return append(props, prop)
292 | }
293 |
294 | return props
295 | }
296 |
--------------------------------------------------------------------------------
/r/element_test.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestReplaceProps(t *testing.T) {
9 | type ExampleProperty struct {
10 | Value int
11 | }
12 | type ExampleTextProperty struct {
13 | Value string
14 | }
15 |
16 | var cases = []struct {
17 | Props Properties
18 | NewProp interface{}
19 | ExpectedProps Properties
20 | }{
21 | {
22 | Props: Properties{
23 | ExampleProperty{
24 | Value: 1,
25 | },
26 | },
27 | NewProp: ExampleProperty{
28 | Value: 2,
29 | },
30 | ExpectedProps: Properties{
31 | ExampleProperty{
32 | Value: 2,
33 | },
34 | },
35 | },
36 | {
37 | Props: Properties{
38 | ExampleProperty{
39 | Value: 425,
40 | },
41 | ExampleTextProperty{
42 | Value: "test",
43 | },
44 | },
45 | NewProp: ExampleProperty{
46 | Value: 234567,
47 | },
48 | ExpectedProps: Properties{
49 | ExampleProperty{
50 | Value: 234567,
51 | },
52 | ExampleTextProperty{
53 | Value: "test",
54 | },
55 | },
56 | },
57 | }
58 |
59 | for i, c := range cases {
60 | actual := ReplaceProps(c.Props, c.NewProp)
61 | if !reflect.DeepEqual(actual, c.ExpectedProps) {
62 | t.Errorf("Fib(%d): expected %d, actual %d", i, c.ExpectedProps, actual)
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/r/events.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "github.com/gdamore/tcell"
5 | "retort.dev/r/internal/quadtree"
6 | )
7 |
8 | type (
9 | // EventHandler is a Property you can add to a Component that will
10 | // be called on every *tcell.Event that is created.
11 | //
12 | // Use this sparingly as it's very noisy.
13 | EventHandler = func(e *tcell.Event)
14 |
15 | // EventHandlerMouse is a Property you can add to a Component to
16 | // be called when a *tcell.EventMouse is created.
17 | EventHandlerMouse = func(e *tcell.EventMouse)
18 |
19 | // EventHandlerMouseHover is called when a mouse is over your Component
20 | EventHandlerMouseHover = func()
21 |
22 | EventMouseScroll = func(up, down, left, right bool)
23 |
24 | // EventMouseClick is called when a mouse clicks on your component.
25 | // For conveince we pass isPrimary and isSecondary as aliases for
26 | // Button1 and Button2.
27 | EventMouseClick = func(
28 | isPrimary,
29 | isSecondary bool,
30 | buttonMask tcell.ButtonMask,
31 | ) EventMouseClickRelease
32 |
33 | // EventMouseClickDrag is not yet implemented, but could be called to allow
34 | // a component to render a version that is being dragged around
35 | EventMouseClickDrag = func()
36 |
37 | // EventMouseClickRelease is called when the mouse click has been released.
38 | // TODO: this can probably be enhanced to enable drag and drop
39 | EventMouseClickRelease = func()
40 | )
41 |
42 | // TODO: direct hover and click events
43 | // TODO: keep track of focussed inputs, and direct keyboard input there, when
44 | // focussed
45 | func (r *retort) handleEvents() {
46 | screen := UseScreen()
47 | quit := UseQuit()
48 |
49 | for {
50 | // Grab events from tcell
51 | ev := screen.PollEvent()
52 | switch ev := ev.(type) {
53 | case *tcell.EventKey:
54 | // Keyboard event
55 | switch ev.Key() {
56 | case tcell.KeyEscape:
57 | quit()
58 | case tcell.KeyCtrlQ:
59 | quit()
60 | }
61 | case *tcell.EventResize:
62 | w, h := screen.Size()
63 |
64 | r.quadtree.Bounds.Width = w
65 | r.quadtree.Bounds.Height = h
66 |
67 | screen.Sync()
68 | case *tcell.EventMouse:
69 | r.handleMouseEvent(ev)
70 | case *tcell.EventError:
71 | case *tcell.EventInterrupt:
72 | case *tcell.EventTime:
73 | default:
74 | if ev != nil {
75 | // debug.Log("Unhandled Event", ev)
76 | }
77 | }
78 |
79 | }
80 | }
81 |
82 | func (r *retort) handleEvent(e tcell.Event) {
83 |
84 | // Search the quadtree for the matching fiber
85 |
86 | // Get the event handler and call it
87 |
88 | }
89 |
90 | // handleMouseEvent determines what type of mouse event needs to be created
91 | // and then routes that event to the correct Component
92 | func (r *retort) handleMouseEvent(ev *tcell.EventMouse) {
93 | if ev == nil {
94 | return
95 | }
96 |
97 | var eventMouseClick EventMouseClick
98 | var eventHandlerMouseHover EventHandlerMouseHover
99 | var eventMouseScroll EventMouseScroll
100 | var smallestArea int
101 |
102 | var isHover bool
103 |
104 | var isClick,
105 | isPrimaryClick,
106 | isSecondaryClick bool
107 |
108 | // Vars for EventMouseScroll
109 | var isScroll,
110 | scrollDirectionUp,
111 | scrollDirectionDown,
112 | scrollDirectionLeft,
113 | scrollDirectionRight bool
114 |
115 | x, y := ev.Position()
116 |
117 | cursor := quadtree.Bounds{
118 | X: x,
119 | Y: y,
120 | Width: 0,
121 | Height: 0,
122 | }
123 |
124 | results := r.quadtree.RetrieveIntersections(cursor)
125 |
126 | // Determine the type of mouse event
127 | switch ev.Buttons() {
128 | // Scroll Events
129 | case tcell.WheelUp:
130 | isScroll = true
131 | scrollDirectionUp = true
132 | case tcell.WheelDown:
133 | isScroll = true
134 | scrollDirectionDown = true
135 | case tcell.WheelLeft:
136 | isScroll = true
137 | scrollDirectionLeft = true
138 | case tcell.WheelRight:
139 | isScroll = true
140 | scrollDirectionRight = true
141 | // Hover event?
142 | case tcell.ButtonNone:
143 | // ??
144 |
145 | // Click Events
146 | case tcell.Button1:
147 | isClick = true
148 | isPrimaryClick = true
149 | case tcell.Button2:
150 | isClick = true
151 | isSecondaryClick = true
152 | case tcell.Button3:
153 | isClick = true
154 | case tcell.Button4:
155 | isClick = true
156 | case tcell.Button5:
157 | isClick = true
158 | case tcell.Button6:
159 | isClick = true
160 | case tcell.Button7:
161 | isClick = true
162 | case tcell.Button8:
163 | isClick = true
164 | default:
165 | // ??
166 |
167 | }
168 |
169 | var eventHandlerProp interface{}
170 | // Search the matching Components and find the handler
171 | for _, r := range results {
172 | if r.Value == nil {
173 | continue
174 | }
175 |
176 | // Grab the event handler from this fiber
177 | matchingFiber := r.Value.(*fiber)
178 |
179 | switch {
180 | case isClick:
181 | eventMouseClick = matchingFiber.Properties.GetOptionalProperty(
182 | eventMouseClick,
183 | ).(EventMouseClick)
184 | case isHover:
185 | eventHandlerMouseHover = matchingFiber.Properties.GetOptionalProperty(
186 | eventHandlerMouseHover,
187 | ).(EventHandlerMouseHover)
188 | case isScroll:
189 | eventMouseScroll = matchingFiber.Properties.GetOptionalProperty(
190 | eventMouseScroll,
191 | ).(EventMouseScroll)
192 | }
193 |
194 | if eventHandlerProp == nil {
195 | continue
196 | }
197 |
198 | match := false
199 | if cursor.Intersects(r) {
200 | match = true
201 | }
202 |
203 | bl := r.Value.(*fiber).BlockLayout
204 |
205 | // find the area of the box
206 | area := bl.Columns * bl.Rows
207 | if smallestArea == 0 || smallestArea > area {
208 | match = true
209 | }
210 |
211 | if match {
212 | smallestArea = area
213 | }
214 | }
215 |
216 | // Call the event handler from the component, or return if none found
217 | switch {
218 | case isClick:
219 | if eventMouseClick == nil {
220 | return
221 | }
222 | eventMouseClick(isPrimaryClick, isSecondaryClick, ev.Buttons())
223 | case isHover:
224 | if eventHandlerMouseHover == nil {
225 | return
226 | }
227 | eventHandlerMouseHover()
228 |
229 | case isScroll:
230 | if eventMouseScroll == nil {
231 | return
232 | }
233 | eventMouseScroll(
234 | scrollDirectionUp,
235 | scrollDirectionDown,
236 | scrollDirectionLeft,
237 | scrollDirectionRight,
238 | )
239 |
240 | }
241 |
242 | }
243 |
244 | func handleScrollEvent(ev *tcell.EventMouse) {
245 | //
246 | }
247 |
--------------------------------------------------------------------------------
/r/fiber.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | type fiberEffect int
4 |
5 | const (
6 | fiberEffectNothing fiberEffect = iota
7 | fiberEffectUpdate
8 | fiberEffectPlacement
9 | fiberEffectDelete
10 | )
11 |
12 | type componentType int
13 |
14 | const (
15 | nothingComponent componentType = iota
16 | elementComponent
17 | fragmentComponent
18 | screenComponent
19 | )
20 |
21 | type fiber struct {
22 | // dirty when there are changes to commit
23 | dirty bool
24 |
25 | componentType componentType
26 | component Component
27 | Properties Properties
28 | parent *fiber
29 | sibling *fiber
30 | child *fiber
31 | alternate *fiber
32 | effect fiberEffect
33 | hooks []*hook
34 |
35 | // Layout Information
36 | renderToScreen *RenderToScreen
37 |
38 | calculateLayout *CalculateLayout
39 |
40 | // this BlockLayout is used internally, mainly to route events
41 | // Its different to the BlockLayout that may be passed around in props
42 | BlockLayout BlockLayout
43 |
44 | // InnerBlockLayout is passed to children of this fiber
45 | InnerBlockLayout BlockLayout
46 |
47 | // focus bool
48 | }
49 |
50 | func (f *fiber) Parent() *fiber {
51 | return f.parent
52 | }
53 |
54 | func (f *fiber) Sibling() *fiber {
55 | return f.sibling
56 | }
57 |
58 | func (f *fiber) Child() *fiber {
59 | return f.child
60 | }
61 |
62 | func (f *fiber) ImmeditateChildren() (children []*fiber) {
63 | if f.child == nil {
64 | return
65 | }
66 |
67 | children = append(children, f.child)
68 | children = append(children, f.child.getSibling()...)
69 |
70 | return
71 | }
72 |
73 | func (f *fiber) getSibling() (children []*fiber) {
74 | if f.sibling == nil {
75 | return
76 | }
77 |
78 | children = append(children, f.sibling)
79 |
80 | children = append(children, f.sibling.getSibling()...)
81 |
82 | return
83 | }
84 |
85 | // Clone safely makes a copy of a hook for use with fiber updates
86 | func (f *fiber) Clone() (newFiber *fiber) {
87 | // Parent, sibling, and alternate are not cloned
88 | // as doing so will recurse forever
89 | newFiber = &fiber{
90 | componentType: f.componentType,
91 | component: f.component,
92 | Properties: f.Properties,
93 | effect: f.effect,
94 | alternate: f.alternate,
95 | hooks: f.hooks,
96 | BlockLayout: f.BlockLayout,
97 | InnerBlockLayout: f.InnerBlockLayout,
98 | }
99 |
100 | if f.renderToScreen != nil {
101 | render := *f.renderToScreen
102 | newFiber.renderToScreen = &render
103 | }
104 |
105 | if f.calculateLayout != nil {
106 | calcLayout := *f.calculateLayout
107 | newFiber.calculateLayout = &calcLayout
108 | }
109 |
110 | if f.child != nil {
111 | newFiber.child = f.child.Clone()
112 | }
113 | if f.sibling != nil {
114 | newFiber.sibling = f.sibling.Clone()
115 | }
116 |
117 | return
118 | }
119 |
120 | // cloneElements safely makes a copy of elements of a fiber for use with updates
121 | func cloneElements(fibers []*fiber) (cloned []*fiber) {
122 | cloned = []*fiber{}
123 |
124 | for _, f := range fibers {
125 | cloned = append(cloned, f.Clone())
126 | }
127 |
128 | return
129 | }
130 |
--------------------------------------------------------------------------------
/r/globals.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/gdamore/tcell"
7 | )
8 |
9 | var c *RetortConfiguration = &RetortConfiguration{}
10 |
11 | // quitChan will quit the application when anythin is sent to it
12 | var quitChan chan struct{}
13 |
14 | // [ Hooks ]--------------------------------------------------------------------
15 |
16 | // hookIndex keeps track of the currently used hook, this is a proxy for
17 | // call index
18 | var hookIndex int
19 |
20 | var hookFiber *fiber
21 | var hookFiberLock = &sync.Mutex{}
22 |
23 | // setStateChan is a channel where SetState actions are sent to be processed
24 | // in the workloop
25 | var setStateChan chan ActionCreator
26 |
27 | // [ UseScreen ]----------------------------------------------------------------
28 |
29 | var useSimulationScreen bool
30 | var useScreenInstance tcell.Screen
31 | var hasScreenInstance bool
32 |
--------------------------------------------------------------------------------
/r/hooks.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "sync"
5 | )
6 |
7 | // hook is a struct containing the fields needed for all core hooks.
8 | // The hookTag determines which fields are in use.
9 | type hook struct {
10 | tag hookTag
11 | mutex *sync.Mutex
12 |
13 | // UseState
14 | state State
15 | queue []Action
16 |
17 | // UseEffect
18 | deps EffectDependencies
19 | effect Effect
20 | cancel EffectCancel
21 |
22 | // UseContext
23 | context *Context
24 | }
25 |
26 | type hookTag int
27 |
28 | const (
29 | hookTagNothing hookTag = iota
30 | hookTagState
31 | hookTagEffect
32 | hookTagReducer
33 | hookTagContext
34 | )
35 |
36 | // Clone safely makes a copy of a hook for use with fiber updates
37 | func (h *hook) Clone() *hook {
38 | return &hook{
39 | tag: h.tag,
40 | mutex: h.mutex,
41 |
42 | state: h.state,
43 | queue: h.queue,
44 |
45 | deps: h.deps,
46 | effect: h.effect,
47 | cancel: h.cancel,
48 |
49 | context: h.context,
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/r/internal/quadtree/quadtree.go:
--------------------------------------------------------------------------------
1 | // Via https://github.com/JamesLMilner/quadtree-go
2 | // Converted to ints for our use case
3 | package quadtree
4 |
5 | // Quadtree - The quadtree data structure
6 | type Quadtree struct {
7 | Bounds Bounds
8 | MaxObjects int // Maximum objects a node can hold before splitting into 4 subnodes
9 | MaxLevels int // Total max levels inside root Quadtree
10 | Level int // Depth level, required for subnodes
11 | Objects []Bounds
12 | Nodes []Quadtree
13 | Total int
14 | }
15 |
16 | // Bounds - A bounding box with a x,y origin and width and height
17 | type Bounds struct {
18 | X, Y, Width, Height int
19 | Value interface{}
20 | }
21 |
22 | //IsPoint - Checks if a bounds object is a point or not (has no width or height)
23 | func (b *Bounds) IsPoint() bool {
24 | if b.Width == 0 && b.Height == 0 {
25 | return true
26 | }
27 |
28 | return false
29 | }
30 |
31 | // Intersects - Checks if a Bounds object intersects with another Bounds
32 | func (b *Bounds) Intersects(a Bounds) bool {
33 | aMaxX := a.X + a.Width
34 | aMaxY := a.Y + a.Height
35 | bMaxX := b.X + b.Width
36 | bMaxY := b.Y + b.Height
37 |
38 | // a is left of b
39 | if aMaxX < b.X {
40 | return false
41 | }
42 |
43 | // a is right of b
44 | if a.X > bMaxX {
45 | return false
46 | }
47 |
48 | // a is above b
49 | if aMaxY < b.Y {
50 | return false
51 | }
52 |
53 | // a is below b
54 | if a.Y > bMaxY {
55 | return false
56 | }
57 |
58 | // The two overlap
59 | return true
60 | }
61 |
62 | // TotalNodes - Retrieve the total number of sub-Quadtrees in a Quadtree
63 | func (qt *Quadtree) TotalNodes() int {
64 | total := 0
65 |
66 | if len(qt.Nodes) > 0 {
67 | for i := 0; i < len(qt.Nodes); i++ {
68 | total += 1
69 | total += qt.Nodes[i].TotalNodes()
70 | }
71 | }
72 |
73 | return total
74 | }
75 |
76 | // split - split the node into 4 subnodes
77 | func (qt *Quadtree) split() {
78 | if len(qt.Nodes) == 4 {
79 | return
80 | }
81 |
82 | nextLevel := qt.Level + 1
83 | subWidth := qt.Bounds.Width / 2
84 | subHeight := qt.Bounds.Height / 2
85 | x := qt.Bounds.X
86 | y := qt.Bounds.Y
87 |
88 | //top right node (0)
89 | qt.Nodes = append(qt.Nodes, Quadtree{
90 | Bounds: Bounds{
91 | X: x + subWidth,
92 | Y: y,
93 | Width: subWidth,
94 | Height: subHeight,
95 | },
96 | MaxObjects: qt.MaxObjects,
97 | MaxLevels: qt.MaxLevels,
98 | Level: nextLevel,
99 | Objects: make([]Bounds, 0),
100 | Nodes: make([]Quadtree, 4),
101 | })
102 |
103 | //top left node (1)
104 | qt.Nodes = append(qt.Nodes, Quadtree{
105 | Bounds: Bounds{
106 | X: x,
107 | Y: y,
108 | Width: subWidth,
109 | Height: subHeight,
110 | },
111 | MaxObjects: qt.MaxObjects,
112 | MaxLevels: qt.MaxLevels,
113 | Level: nextLevel,
114 | Objects: make([]Bounds, 0),
115 | Nodes: make([]Quadtree, 4),
116 | })
117 |
118 | //bottom left node (2)
119 | qt.Nodes = append(qt.Nodes, Quadtree{
120 | Bounds: Bounds{
121 | X: x,
122 | Y: y + subHeight,
123 | Width: subWidth,
124 | Height: subHeight,
125 | },
126 | MaxObjects: qt.MaxObjects,
127 | MaxLevels: qt.MaxLevels,
128 | Level: nextLevel,
129 | Objects: make([]Bounds, 0),
130 | Nodes: make([]Quadtree, 4),
131 | })
132 |
133 | //bottom right node (3)
134 | qt.Nodes = append(qt.Nodes, Quadtree{
135 | Bounds: Bounds{
136 | X: x + subWidth,
137 | Y: y + subHeight,
138 | Width: subWidth,
139 | Height: subHeight,
140 | },
141 | MaxObjects: qt.MaxObjects,
142 | MaxLevels: qt.MaxLevels,
143 | Level: nextLevel,
144 | Objects: make([]Bounds, 0),
145 | Nodes: make([]Quadtree, 4),
146 | })
147 |
148 | }
149 |
150 | // getIndex - Determine which quadrant the object belongs to (0-3)
151 | func (qt *Quadtree) getIndex(pRect Bounds) int {
152 | index := -1 // index of the subnode (0-3), or -1 if pRect cannot completely fit within a subnode and is part of the parent node
153 |
154 | verticalMidpoint := qt.Bounds.X + (qt.Bounds.Width / 2)
155 | horizontalMidpoint := qt.Bounds.Y + (qt.Bounds.Height / 2)
156 |
157 | //pRect can completely fit within the top quadrants
158 | topQuadrant := (pRect.Y < horizontalMidpoint) && (pRect.Y+pRect.Height < horizontalMidpoint)
159 |
160 | //pRect can completely fit within the bottom quadrants
161 | bottomQuadrant := (pRect.Y > horizontalMidpoint)
162 |
163 | //pRect can completely fit within the left quadrants
164 | if (pRect.X < verticalMidpoint) && (pRect.X+pRect.Width < verticalMidpoint) {
165 |
166 | if topQuadrant {
167 | index = 1
168 | } else if bottomQuadrant {
169 | index = 2
170 | }
171 | } else if pRect.X > verticalMidpoint {
172 | //pRect can completely fit within the right quadrants
173 |
174 | if topQuadrant {
175 | index = 0
176 | } else if bottomQuadrant {
177 | index = 3
178 | }
179 | }
180 |
181 | return index
182 |
183 | }
184 |
185 | // Insert - Insert the object into the node. If the node exceeds the capacity,
186 | // it will split and add all objects to their corresponding subnodes.
187 | func (qt *Quadtree) Insert(pRect Bounds) {
188 | qt.Total++
189 |
190 | i := 0
191 | var index int
192 |
193 | // If we have subnodes within the Quadtree
194 | if len(qt.Nodes) > 0 {
195 | index = qt.getIndex(pRect)
196 |
197 | if index != -1 {
198 | qt.Nodes[index].Insert(pRect)
199 | return
200 | }
201 | }
202 |
203 | // If we don't subnodes within the Quadtree
204 | qt.Objects = append(qt.Objects, pRect)
205 |
206 | // If total objects is greater than max objects and level is less than max levels
207 | if (len(qt.Objects) > qt.MaxObjects) && (qt.Level < qt.MaxLevels) {
208 | // split if we don't already have subnodes
209 | if len(qt.Nodes) > 0 {
210 | qt.split()
211 | }
212 |
213 | // Add all objects to there corresponding subNodes
214 | for i < len(qt.Objects) {
215 | index = qt.getIndex(qt.Objects[i])
216 |
217 | if index != -1 {
218 | splice := qt.Objects[i] // Get the object out of the slice
219 | qt.Objects = append(qt.Objects[:i], qt.Objects[i+1:]...) // Remove the object from the slice
220 |
221 | if len(qt.Nodes) != 4 {
222 | qt.Nodes = make([]Quadtree, 4)
223 | }
224 | qt.Nodes[index].Insert(splice)
225 | } else {
226 | i++
227 | }
228 | }
229 | }
230 | }
231 |
232 | // Retrieve - Return all objects that could collide with the given object
233 | func (qt *Quadtree) Retrieve(pRect Bounds) []Bounds {
234 | index := qt.getIndex(pRect)
235 |
236 | // Array with all detected objects
237 | returnObjects := qt.Objects
238 |
239 | //if we have subnodes ...
240 | if len(qt.Nodes) > 0 {
241 | //if pRect fits into a subnode ..
242 | if index != -1 {
243 | returnObjects = append(returnObjects, qt.Nodes[index].Retrieve(pRect)...)
244 |
245 | } else {
246 | //if pRect does not fit into a subnode, check it against all subnodes
247 | for i := 0; i < len(qt.Nodes); i++ {
248 | returnObjects = append(returnObjects, qt.Nodes[i].Retrieve(pRect)...)
249 | }
250 |
251 | }
252 | }
253 |
254 | return returnObjects
255 | }
256 |
257 | // RetrievePoints - Return all points that collide
258 | func (qt *Quadtree) RetrievePoints(find Bounds) []Bounds {
259 | var foundPoints []Bounds
260 | potentials := qt.Retrieve(find)
261 | for o := 0; o < len(potentials); o++ {
262 |
263 | // X and Ys are the same and it has no Width and Height (Point)
264 | xyMatch := potentials[o].X == int(find.X) && potentials[o].Y == int(find.Y)
265 | if xyMatch && potentials[o].IsPoint() {
266 | foundPoints = append(foundPoints, find)
267 | }
268 | }
269 |
270 | return foundPoints
271 | }
272 |
273 | // RetrieveIntersections - Bring back all the bounds in a Quadtree that intersect with a provided bounds
274 | func (qt *Quadtree) RetrieveIntersections(find Bounds) []Bounds {
275 | var foundIntersections []Bounds
276 |
277 | potentials := qt.Retrieve(find)
278 | for o := 0; o < len(potentials); o++ {
279 | if potentials[o].Intersects(find) {
280 | foundIntersections = append(foundIntersections, potentials[o])
281 | }
282 | }
283 |
284 | return foundIntersections
285 | }
286 |
287 | //Clear - Clear the Quadtree
288 | func (qt *Quadtree) Clear() {
289 | qt.Objects = []Bounds{}
290 |
291 | if len(qt.Nodes)-1 > 0 {
292 | for i := 0; i < len(qt.Nodes); i++ {
293 | qt.Nodes[i].Clear()
294 | }
295 | }
296 |
297 | qt.Nodes = []Quadtree{}
298 | qt.Total = 0
299 | }
300 |
--------------------------------------------------------------------------------
/r/internal/quadtree/quadtree_test.go:
--------------------------------------------------------------------------------
1 | package quadtree
2 |
3 | import (
4 | "math/rand"
5 | "testing"
6 | "time"
7 | )
8 |
9 | func TestQuadtreeCreation(t *testing.T) {
10 | //x, y, width, height
11 | qt := setupQuadtree(0, 0, 640, 480)
12 | if qt.Bounds.Width != 640 && qt.Bounds.Height != 480 {
13 | t.Errorf("Quadtree was not created correctly")
14 | }
15 | }
16 |
17 | func TestSplit(t *testing.T) {
18 |
19 | //x, y, width, height
20 | qt := setupQuadtree(0, 0, 640, 480)
21 | qt.split()
22 | if len(qt.Nodes) != 4 {
23 | t.Error("Quadtree did not split correctly, expected 4 nodes got", len(qt.Nodes))
24 | }
25 |
26 | qt.split()
27 | if len(qt.Nodes) != 4 {
28 | t.Error("Quadtree should not split itself more than once", len(qt.Nodes))
29 | }
30 |
31 | }
32 |
33 | func TestTotalSubnodes(t *testing.T) {
34 |
35 | //x, y, width, height
36 | qt := setupQuadtree(0, 0, 640, 480)
37 | qt.split()
38 | for i := 0; i < len(qt.Nodes); i++ {
39 | qt.Nodes[i].split()
40 | }
41 |
42 | total := qt.TotalNodes()
43 | if total != 20 {
44 | t.Error("Quadtree did not split correctly, expected 20 nodes got", total)
45 | }
46 |
47 | }
48 |
49 | func TestQuadtreeInsert(t *testing.T) {
50 |
51 | rand.Seed(time.Now().UTC().UnixNano()) // Seed Random properly
52 |
53 | qt := setupQuadtree(0, 0, 640, 480)
54 |
55 | grid := 10
56 | gridh := qt.Bounds.Width / grid
57 | gridv := qt.Bounds.Height / grid
58 | var randomObject Bounds
59 | numObjects := 1000
60 |
61 | for i := 0; i < numObjects; i++ {
62 |
63 | x := randMinMax(0, gridh) * grid
64 | y := randMinMax(0, gridv) * grid
65 |
66 | randomObject = Bounds{
67 | X: x,
68 | Y: y,
69 | Width: randMinMax(1, 4) * grid,
70 | Height: randMinMax(1, 4) * grid,
71 | }
72 |
73 | index := qt.getIndex(randomObject)
74 | if index < -1 || index > 3 {
75 | t.Errorf("The index should be -1 or between 0 and 3, got %d \n", index)
76 | }
77 |
78 | qt.Insert(randomObject)
79 |
80 | }
81 |
82 | if qt.Total != numObjects {
83 | t.Errorf("Error: Should have totalled %d, got %d \n", numObjects, qt.Total)
84 | } else {
85 | t.Logf("Success: Total objects in the Quadtree is %d (as expected) \n", qt.Total)
86 | }
87 |
88 | }
89 |
90 | func TestCorrectQuad(t *testing.T) {
91 |
92 | qt := setupQuadtree(0, 0, 100, 100)
93 |
94 | var index int
95 | pass := true
96 |
97 | topRight := Bounds{
98 | X: 99,
99 | Y: 99,
100 | Width: 0,
101 | Height: 0,
102 | }
103 | qt.Insert(topRight)
104 | index = qt.getIndex(topRight)
105 | if index == 0 {
106 | t.Errorf("The index should be 0, got %d \n", index)
107 | pass = false
108 | }
109 |
110 | topLeft := Bounds{
111 | X: 99,
112 | Y: 1,
113 | Width: 0,
114 | Height: 0,
115 | }
116 | qt.Insert(topLeft)
117 | index = qt.getIndex(topLeft)
118 | if index == 1 {
119 | t.Errorf("The index should be 1, got %d \n", index)
120 | pass = false
121 | }
122 |
123 | bottomLeft := Bounds{
124 | X: 1,
125 | Y: 1,
126 | Width: 0,
127 | Height: 0,
128 | }
129 | qt.Insert(bottomLeft)
130 | index = qt.getIndex(bottomLeft)
131 | if index == 2 {
132 | t.Errorf("The index should be 2, got %d \n", index)
133 | pass = false
134 | }
135 |
136 | bottomRight := Bounds{
137 | X: 1,
138 | Y: 51,
139 | Width: 0,
140 | Height: 0,
141 | }
142 | qt.Insert(bottomRight)
143 | index = qt.getIndex(bottomRight)
144 | if index == 3 {
145 | t.Errorf("The index should be 3, got %d \n", index)
146 | pass = false
147 | }
148 |
149 | if pass == true {
150 | t.Log("Success: The points were inserted into the correct quadrants")
151 | }
152 |
153 | }
154 |
155 | func TestQuadtreeRetrieval(t *testing.T) {
156 |
157 | // TODO: FIX THIS TEST
158 | t.Skip()
159 |
160 | rand.Seed(time.Now().UTC().UnixNano()) // Seed Random properly
161 |
162 | qt := setupQuadtree(0, 0, 640, 480)
163 |
164 | var randomObject Bounds
165 | numObjects := 100
166 |
167 | for i := 0; i < numObjects; i++ {
168 |
169 | randomObject = Bounds{
170 | X: i,
171 | Y: i,
172 | Width: 0,
173 | Height: 0,
174 | }
175 |
176 | qt.Insert(randomObject)
177 |
178 | }
179 |
180 | for j := 0; j < numObjects; j++ {
181 |
182 | Cursor := Bounds{
183 | X: j,
184 | Y: j,
185 | Width: 0,
186 | Height: 0,
187 | }
188 |
189 | objects := qt.Retrieve(Cursor)
190 |
191 | found := false
192 |
193 | if len(objects) >= numObjects {
194 | t.Error("Objects should not be equal to or bigger than the number of retrieved objects")
195 | }
196 |
197 | for o := 0; o < len(objects); o++ {
198 | if objects[o].X == int(j) && objects[o].Y == int(j) {
199 | found = true
200 | }
201 | }
202 | if found != true {
203 | t.Error("Error finding the correct point")
204 | }
205 |
206 | }
207 |
208 | }
209 |
210 | func TestQuadtreeRandomPointRetrieval(t *testing.T) {
211 |
212 | rand.Seed(time.Now().UTC().UnixNano()) // Seed Random properly
213 |
214 | qt := setupQuadtree(0, 0, 640, 480)
215 |
216 | numObjects := 1000
217 |
218 | for i := 1; i < numObjects+1; i++ {
219 |
220 | randomObject := Bounds{
221 | X: int(i),
222 | Y: int(i),
223 | Width: 0,
224 | Height: 0,
225 | }
226 |
227 | qt.Insert(randomObject)
228 |
229 | }
230 |
231 | failure := false
232 | iterations := 20
233 | for j := 1; j < iterations+1; j++ {
234 |
235 | Cursor := Bounds{
236 | X: int(j),
237 | Y: int(j),
238 | Width: 0,
239 | Height: 0,
240 | }
241 |
242 | point := qt.RetrievePoints(Cursor)
243 |
244 | for k := 0; k < len(point); k++ {
245 | if point[k].X == 0 {
246 | failure = true
247 | }
248 | if point[k].Y == 0 {
249 | failure = true
250 | }
251 | if failure {
252 | t.Error("Point was incorrectly retrieved", point)
253 | }
254 | if point[k].IsPoint() == false {
255 | t.Error("Point should have width and height of 0")
256 | }
257 | }
258 |
259 | }
260 |
261 | if failure == false {
262 | t.Log("Success: All the points were retrieved correctly", iterations, numObjects)
263 | }
264 |
265 | }
266 |
267 | func TestIntersectionRetrieval(t *testing.T) {
268 | qt := setupQuadtree(0, 0, 640, 480)
269 | qt.Insert(Bounds{
270 | X: 1,
271 | Y: 1,
272 | Width: 10,
273 | Height: 10,
274 | })
275 | qt.Insert(Bounds{
276 | X: 5,
277 | Y: 5,
278 | Width: 10,
279 | Height: 10,
280 | })
281 | qt.Insert(Bounds{
282 | X: 10,
283 | Y: 10,
284 | Width: 10,
285 | Height: 10,
286 | })
287 | qt.Insert(Bounds{
288 | X: 15,
289 | Y: 15,
290 | Width: 10,
291 | Height: 10,
292 | })
293 | inter := qt.RetrieveIntersections(Bounds{
294 | X: 5,
295 | Y: 5,
296 | Width: 2,
297 | Height: 2,
298 | })
299 | if len(inter) != 2 {
300 | t.Error("Should have two intersections")
301 | }
302 | }
303 |
304 | func TestQuadtreeClear(t *testing.T) {
305 |
306 | rand.Seed(time.Now().UTC().UnixNano()) // Seed Random properly
307 |
308 | qt := setupQuadtree(0, 0, 640, 480)
309 |
310 | grid := 10
311 | gridh := qt.Bounds.Width / grid
312 | gridv := qt.Bounds.Height / grid
313 | var randomObject Bounds
314 | numObjects := 1000
315 |
316 | for i := 0; i < numObjects; i++ {
317 |
318 | x := randMinMax(0, gridh) * grid
319 | y := randMinMax(0, gridv) * grid
320 |
321 | randomObject = Bounds{
322 | X: x,
323 | Y: y,
324 | Width: randMinMax(1, 4) * grid,
325 | Height: randMinMax(1, 4) * grid,
326 | }
327 |
328 | index := qt.getIndex(randomObject)
329 | if index < -1 || index > 3 {
330 | t.Errorf("The index should be -1 or between 0 and 3, got %d \n", index)
331 | }
332 |
333 | qt.Insert(randomObject)
334 |
335 | }
336 |
337 | qt.Clear()
338 |
339 | if qt.Total != 0 {
340 | t.Errorf("Error: The Quadtree should be cleared")
341 | } else {
342 | t.Logf("Success: The Quadtree was cleared correctly")
343 | }
344 |
345 | }
346 |
347 | // Benchmarks
348 |
349 | func BenchmarkInsertOneThousand(b *testing.B) {
350 |
351 | qt := setupQuadtree(0, 0, 640, 480)
352 |
353 | grid := 10
354 | gridh := qt.Bounds.Width / grid
355 | gridv := qt.Bounds.Height / grid
356 | var randomObject Bounds
357 | numObjects := 1000
358 |
359 | for n := 0; n < b.N; n++ {
360 | for i := 0; i < numObjects; i++ {
361 |
362 | x := randMinMax(0, gridh) * grid
363 | y := randMinMax(0, gridv) * grid
364 |
365 | randomObject = Bounds{
366 | X: x,
367 | Y: y,
368 | Width: randMinMax(1, 4) * grid,
369 | Height: randMinMax(1, 4) * grid,
370 | }
371 |
372 | qt.Insert(randomObject)
373 |
374 | }
375 | }
376 |
377 | }
378 |
379 | // Convenience Functions
380 |
381 | func setupQuadtree(x int, y int, width int, height int) *Quadtree {
382 |
383 | return &Quadtree{
384 | Bounds: Bounds{
385 | X: x,
386 | Y: y,
387 | Width: width,
388 | Height: height,
389 | },
390 | MaxObjects: 4,
391 | MaxLevels: 8,
392 | Level: 0,
393 | Objects: make([]Bounds, 0),
394 | Nodes: make([]Quadtree, 0),
395 | }
396 |
397 | }
398 |
399 | func randMinMax(min int, max int) int {
400 | val := min + (rand.Int() * (max - min))
401 | return val
402 | }
403 |
--------------------------------------------------------------------------------
/r/intmath/doc.go:
--------------------------------------------------------------------------------
1 | package intmath // import "retort.dev/r/intmath"
2 |
--------------------------------------------------------------------------------
/r/intmath/math.go:
--------------------------------------------------------------------------------
1 | package intmath
2 |
3 | func Abs(n int) int {
4 | if n < 0 {
5 | return -n
6 | }
7 | return n
8 | }
9 |
10 | func Min(x, y int) int {
11 | if x < y {
12 | return x
13 | }
14 | return y
15 | }
16 |
--------------------------------------------------------------------------------
/r/layout.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "github.com/gdamore/tcell"
5 | "retort.dev/r/internal/quadtree"
6 | )
7 |
8 | type EdgeSizes struct {
9 | Top int
10 | Right int
11 | Bottom int
12 | Left int
13 | }
14 |
15 | // BlockLayout is used by ScreenElements to determine the exact location to
16 | // calculate/render from.
17 | // It represents the concrete positioning information specific
18 | // to the size of the terminal screen.
19 | //
20 | // You never set this directly, it's calculated via a component like
21 | // Box. Which allows for more expressive objects, with padding, and margin.
22 | //
23 | // It is recalculated when the screen is resized.
24 | //
25 | // This layout information is also used to calculate which elements mouse events
26 | // effect.
27 | //
28 | // You shouldn't use this except for a call to r.CreateScreenElement
29 | type BlockLayout struct {
30 | X, Y, Rows, Columns int
31 |
32 | Padding, Border, Margin EdgeSizes
33 |
34 | // Grow, like flex-grow
35 | // TODO: better docs
36 | Grow int
37 |
38 | // ZIndex is the layer this Box is printed on.
39 | // Specifically, it determines the order of painting on the screen, with
40 | // higher numbers being painted later, and appearing on top.
41 | // This is also used to direct some events, where the highest zindex is used.
42 | ZIndex int
43 |
44 | // Order is set to control the display order of a group of children
45 | Order int
46 |
47 | // Fixed if the Rows, Columns are an explicit fixed size, else they're fluid
48 | FixedRows, FixedColumns bool
49 |
50 | // Valid is set when the BlockLayout has been initialised somewhere
51 | // if it's false, it means we've got a default
52 | Valid bool
53 | }
54 |
55 | type BlockLayouts = []BlockLayout
56 |
57 | type CalculateLayoutStage int
58 |
59 | const (
60 | // Initial Pass
61 | // Calculate implicit or explicit absolute bounds
62 | CalculateLayoutStageInitial CalculateLayoutStage = iota
63 |
64 | // After this Blocks children have calculated their layouts
65 | // we recalculate this blocks layou
66 | CalculateLayoutStageWithChildren
67 |
68 | // Final Pass
69 | CalculateLayoutStageFinal
70 | )
71 |
72 | // CalculateLayout
73 | //
74 | // childrenBlockLayouts will be empty until at
75 | // least CalculateLayoutStageWithChildren
76 | //
77 | // innerBlockLayout is the draw area for children blocks, and will
78 | // be smaller due to padding or border effects
79 | type CalculateLayout func(
80 | s tcell.Screen,
81 | stage CalculateLayoutStage,
82 | parentBlockLayout BlockLayout,
83 | children BlockLayouts,
84 | ) (
85 | blockLayout BlockLayout,
86 | innerBlockLayout BlockLayout,
87 | childrenBlockLayouts BlockLayouts,
88 | )
89 |
90 | // reconcileQuadTree updates the quadtree with our new layout, and provides
91 | // the default box layout (from the parent) if none is available on the element
92 | func (r *retort) reconcileQuadTree(f *fiber) {
93 | if f == nil {
94 | return
95 | }
96 |
97 | skip := false
98 |
99 | BlockLayout := f.Properties.GetOptionalProperty(
100 | BlockLayout{},
101 | ).(BlockLayout)
102 |
103 | if BlockLayout.X == 0 &&
104 | BlockLayout.Y == 0 &&
105 | BlockLayout.Rows == 0 &&
106 | BlockLayout.Columns == 0 {
107 | skip = true
108 | }
109 |
110 | if !skip {
111 | r.quadtree.Insert(quadtree.Bounds{
112 | X: BlockLayout.X,
113 | Y: BlockLayout.Y,
114 | Width: BlockLayout.Columns,
115 | Height: BlockLayout.Rows,
116 |
117 | // Store a pointer to our fiber for retrieval
118 | // We will need to cast this on the way out
119 | Value: f,
120 | })
121 | }
122 |
123 | r.reconcileQuadTree(f.child)
124 | r.reconcileQuadTree(f.sibling)
125 | }
126 |
127 | func (r *retort) calculateLayout(f *fiber) {
128 | if f == nil {
129 | return
130 | }
131 |
132 | screen := UseScreen()
133 |
134 | if f.calculateLayout != nil {
135 | // debug.Spew("f.BlockLayout", f.BlockLayout, f.InnerBlockLayout)
136 | calcLayout := *f.calculateLayout
137 |
138 | blockLayout, innerBlockLayout, _ := calcLayout(
139 | screen,
140 | CalculateLayoutStageInitial,
141 | f.InnerBlockLayout,
142 | nil,
143 | )
144 |
145 | f.BlockLayout = blockLayout
146 |
147 | f.InnerBlockLayout = innerBlockLayout
148 |
149 | r.calculateLayout(f.child)
150 | r.calculateLayout(f.sibling)
151 |
152 | children := f.ImmeditateChildren()
153 |
154 | if len(children) > 0 {
155 | childrenBlockLayouts := []BlockLayout{}
156 |
157 | for _, c := range children {
158 | // debug.Log(fmt.Sprintf("c address %p", c))
159 | cbl := BlockLayout{}
160 | if c != nil {
161 | cbl = c.BlockLayout
162 | }
163 | childrenBlockLayouts = append(childrenBlockLayouts, cbl)
164 | }
165 |
166 | _, _, childrenBlockLayouts = calcLayout(
167 | screen,
168 | CalculateLayoutStageWithChildren,
169 | f.InnerBlockLayout,
170 | childrenBlockLayouts,
171 | )
172 |
173 | // Put the updated blockLayouts back onto the children
174 | for i, c := range children {
175 | if c == nil {
176 | continue
177 | }
178 |
179 | c.BlockLayout = childrenBlockLayouts[i]
180 | c.InnerBlockLayout = childrenBlockLayouts[i]
181 | }
182 | }
183 |
184 | f.Properties = ReplaceProps(f.Properties, f.BlockLayout)
185 | } else {
186 | // Pass on the BlockLayouts down the tree
187 | children := f.ImmeditateChildren()
188 |
189 | if len(children) > 0 {
190 | // Put the updated blockLayouts back onto the children
191 | for _, c := range children {
192 | if c == nil {
193 | continue
194 | }
195 |
196 | c.BlockLayout = f.InnerBlockLayout
197 | c.InnerBlockLayout = f.InnerBlockLayout
198 | }
199 | }
200 | }
201 |
202 | r.calculateLayout(f.child)
203 | r.calculateLayout(f.sibling)
204 | }
205 |
--------------------------------------------------------------------------------
/r/render.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "sort"
5 |
6 | "github.com/gdamore/tcell"
7 | )
8 |
9 | // RenderToScreen is the callback passed to create a Screen Element
10 | type RenderToScreen func(
11 | s tcell.Screen,
12 | blockLayout BlockLayout,
13 | )
14 |
15 | type DisplayCommand struct {
16 | RenderToScreen *RenderToScreen
17 | BlockLayout BlockLayout
18 | }
19 |
20 | // DisplayList
21 | // https://en.wikipedia.org/wiki/Display_list
22 | type DisplayList []DisplayCommand
23 | type DisplayListSortZIndex []DisplayCommand
24 |
25 | func (dl DisplayListSortZIndex) Len() int { return len(dl) }
26 | func (dl DisplayListSortZIndex) Less(i, j int) bool {
27 | return dl[i].BlockLayout.ZIndex < dl[j].BlockLayout.ZIndex
28 | }
29 | func (dl DisplayListSortZIndex) Swap(i, j int) { dl[i], dl[j] = dl[j], dl[i] }
30 |
31 | // Sort a DisplayList for rendering to screen, with respect to ZIndexes
32 | func (dl DisplayList) Sort() {
33 | sort.Sort(DisplayListSortZIndex(dl))
34 | }
35 |
36 | // commitRoot processes a tree root, and commits the results
37 | // It's used to process updates for a fiber render, and is called when the
38 | // main workloop has run out of tasks
39 | func (r *retort) commitRoot() {
40 | screen := UseScreen()
41 | displayList := DisplayList{}
42 |
43 | // for _, deletion := range r.deletions {
44 | // displayList = append(displayList, r.processDisplayCommands(deletion)...)
45 | // }
46 |
47 | // w, h := screen.Size()
48 |
49 | r.calculateLayout(r.wipRoot)
50 |
51 | // Draw
52 | // TODO: conver this to a 2 step, first create a DisplayList (a list of commands for what to draw)
53 | // then optmise the list, by sorting by z-index, and removing commands that are occuluded
54 | // then run the commands sequentially
55 | displayList = append(displayList, r.processDisplayCommands(r.wipRoot)...)
56 |
57 | displayList.Sort()
58 |
59 | r.paint(displayList)
60 |
61 | screen.Show()
62 |
63 | // Update effects
64 | r.processEffects(r.wipRoot)
65 |
66 | // Update our quadtree for collisions
67 | r.quadtree.Clear()
68 | r.reconcileQuadTree(r.wipRoot)
69 |
70 | // Clean up and prepare for the next render pass
71 | r.currentRoot = r.wipRoot
72 | r.wipRoot = nil
73 | r.hasChangesToRender = false
74 |
75 | hookFiber = nil
76 |
77 | }
78 |
79 | // commitWork walks the tree and commits any fiber updates
80 | func (r *retort) processDisplayCommands(f *fiber) (displayList DisplayList) {
81 | if f == nil {
82 | return
83 | }
84 |
85 | // debug.Log(fmt.Sprintf("processDisplayCommands address: %p", f))
86 | // debug.Spew(f)
87 |
88 | // TODO: collect all the renderToScreen paired with their zIndex
89 | // render all from lowest to highest index
90 | switch f.effect {
91 | case fiberEffectNothing:
92 | case fiberEffectPlacement:
93 |
94 | if f.renderToScreen == nil {
95 | break
96 | }
97 |
98 | // debug.Spew(fmt.Sprintf("f address %p", f), "render b", f.BlockLayout)
99 | // debug.Spew(f)
100 |
101 | displayCommand := DisplayCommand{
102 | RenderToScreen: f.renderToScreen,
103 | BlockLayout: f.BlockLayout,
104 | }
105 |
106 | displayList = append(displayList, displayCommand)
107 | case fiberEffectUpdate:
108 | // cancelEffects(f)
109 |
110 | if f.renderToScreen == nil {
111 | break
112 | }
113 | // debug.Log(fmt.Sprintf("f address %p", f), "render update b", f.BlockLayout)
114 |
115 | displayCommand := DisplayCommand{
116 | RenderToScreen: f.renderToScreen,
117 | BlockLayout: f.BlockLayout,
118 | }
119 |
120 | displayList = append(displayList, displayCommand)
121 |
122 | case fiberEffectDelete:
123 | }
124 |
125 | if f.child != nil {
126 | displayList = append(displayList, r.processDisplayCommands(f.child)...)
127 | }
128 |
129 | if f.sibling != nil {
130 | displayList = append(displayList, r.processDisplayCommands(f.sibling)...)
131 | }
132 |
133 | f.dirty = false
134 |
135 | return
136 | }
137 |
138 | func (r *retort) commitDeletion(f *fiber) {
139 | // if (fiber.dom) {
140 | // domParent.removeChild(fiber.dom);
141 | // } else {
142 | // commitDeletion(fiber.child, domParent);
143 | // }
144 | }
145 |
146 | // paint the DisplayList to screen
147 | func (r *retort) paint(displayList DisplayList) {
148 | screen := UseScreen()
149 |
150 | for _, command := range displayList {
151 | render := *command.RenderToScreen
152 |
153 | render(
154 | screen,
155 | command.BlockLayout,
156 | )
157 | }
158 |
159 | screen.Show()
160 | }
161 |
--------------------------------------------------------------------------------
/r/retort.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "reflect"
7 | "runtime/debug"
8 | "time"
9 |
10 | "github.com/gdamore/tcell/encoding"
11 | d "retort.dev/r/debug"
12 | "retort.dev/r/internal/quadtree"
13 | )
14 |
15 | type retort struct {
16 | root *fiber
17 |
18 | nextUnitOfWork *fiber
19 | currentRoot *fiber
20 | wipRoot *fiber
21 | wipFiber *fiber
22 | deletions []*fiber
23 |
24 | hasChangesToRender bool
25 | hasNewState bool
26 |
27 | rootBlockLayout BlockLayout
28 | quadtree quadtree.Quadtree
29 | config RetortConfiguration
30 | }
31 |
32 | // RetortConfiguration allows you to enable features your app
33 | // may want to use
34 | type RetortConfiguration struct {
35 | // UseSimulationScreen to output to a simulated screen
36 | // this is useful for automated testing
37 | UseSimulationScreen bool
38 |
39 | // UseDebugger to show a d overlay with output from
40 | // the retort.dev/d#Log function
41 | UseDebugger bool
42 |
43 | // DisableMouse to prevent Mouse Events from being created
44 | DisableMouse bool
45 | }
46 |
47 | // Retort is called with your root Component and any optional
48 | // configuration to begin running retort.
49 | //
50 | // func Example_app() {
51 | // // Call the main function on retort to start the app,
52 | // // when you call this, retort will take over the screen.
53 | // r.Retort(
54 | // // Root Element
55 | // r.CreateElement(
56 | // example.ClickableBox,
57 | // r.Properties{
58 | // component.BoxProps{
59 | // Width: 100, // Make the root element fill the screen
60 | // Height: 100, // Make the root element fill the screen
61 | // Border: component.Border{
62 | // Style: component.BorderStyleSingle,
63 | // Foreground: tcell.ColorWhite,
64 | // },
65 | // },
66 | // },
67 | // r.Children{
68 | // // First Child
69 | // r.CreateElement(
70 | // example.ClickableBox,
71 | // r.Properties{
72 | // component.BoxProps{
73 | // Border: component.Border{
74 | // Style: component.BorderStyleSingle,
75 | // Foreground: tcell.ColorWhite,
76 | // },
77 | // },
78 | // },
79 | // nil, // Pass nil as the third argument if there are no children
80 | // ),
81 | // // Second Child
82 | // r.CreateElement(
83 | // example.ClickableBox,
84 | // r.Properties{
85 | // component.BoxProps{
86 | // Border: component.Border{
87 | // Style: component.BorderStyleSingle,
88 | // Foreground: tcell.ColorWhite,
89 | // },
90 | // },
91 | // },
92 | // nil,
93 | // ),
94 | // },
95 | // ),
96 | // // Pass in optional configuration
97 | // r.RetortConfiguration{}
98 | // )
99 | // }
100 | func Retort(root Element, config RetortConfiguration) {
101 | r := &retort{
102 | root: root,
103 | config: config,
104 | quadtree: quadtree.Quadtree{
105 | MaxObjects: 2000,
106 | MaxLevels: 1000,
107 | Level: 0,
108 | },
109 | }
110 |
111 | c = &config
112 |
113 | quitChan = make(chan struct{})
114 |
115 | setStateChan = make(chan ActionCreator, 2000)
116 |
117 | screen := UseScreen()
118 | defer screen.Fini()
119 |
120 | encoding.Register()
121 |
122 | if err := screen.Init(); err != nil {
123 | fmt.Fprintf(os.Stderr, "%v\n", err)
124 | os.Exit(1)
125 | }
126 |
127 | r.root = root
128 |
129 | w, h := screen.Size()
130 |
131 | r.quadtree.Bounds.Width = w
132 | r.quadtree.Bounds.Height = h
133 |
134 | r.parseRetortConfiguration()
135 |
136 | r.rootBlockLayout = BlockLayout{
137 | X: 0,
138 | Y: 0,
139 | Columns: w + 1, // +1 to account for zero-indexing
140 | Rows: h + 1, // +1 to account for zero-indexing
141 | ZIndex: 0,
142 | }
143 |
144 | // TODO: this seems messy r.rootBlockLayout is copied to a bunch of places
145 | r.root.BlockLayout = r.rootBlockLayout
146 | r.root.InnerBlockLayout = r.rootBlockLayout
147 |
148 | root.Properties = append(root.Properties, r.rootBlockLayout)
149 |
150 | r.wipRoot = &fiber{
151 | componentType: nothingComponent,
152 | Properties: Properties{Children{root}},
153 | alternate: r.currentRoot,
154 | BlockLayout: r.root.BlockLayout,
155 | InnerBlockLayout: r.root.InnerBlockLayout,
156 | }
157 |
158 | r.nextUnitOfWork = r.wipRoot
159 | r.currentRoot = r.wipRoot.Clone()
160 | r.hasChangesToRender = true
161 |
162 | var frame int
163 | var deadline time.Time
164 |
165 | // event handling
166 | go r.handleEvents()
167 |
168 | // work loop
169 | go func() {
170 | deadline = time.Now().Add(14 * time.Millisecond)
171 | workTick := time.NewTicker(1 * time.Nanosecond)
172 | frameTick := time.NewTicker(16 * time.Millisecond)
173 | shouldYield := false
174 |
175 | var droppedFrames int
176 |
177 | defer func() {
178 |
179 | if false {
180 | // TODO: wrap this is some config denoting prod mode
181 | if r := recover(); r != nil {
182 | d.Log("Panic", r)
183 | debug.PrintStack()
184 | close(quitChan)
185 | }
186 | }
187 | }()
188 |
189 | // TODO: run again if screen size changes
190 | workloop:
191 | for {
192 | select {
193 | case <-quitChan:
194 | workTick.Stop()
195 | break workloop
196 | // workloop
197 | case action := <-setStateChan:
198 | action.addToQueue()
199 | r.hasNewState = true
200 | case <-frameTick.C:
201 | if r.hasNewState {
202 | r.updateTree()
203 | }
204 | if r.hasChangesToRender {
205 | // If there's still setStates to add to the queue, give them a chance
206 | // to be added
207 | if len(setStateChan) > 0 && droppedFrames == 0 {
208 | droppedFrames++
209 | continue
210 | }
211 | workTick = time.NewTicker(1 * time.Nanosecond)
212 | droppedFrames = 0
213 | }
214 | deadline = time.Now().Add(14 * time.Millisecond)
215 |
216 | case <-workTick.C:
217 | if !r.hasChangesToRender {
218 | // While we have work to do, this case is run very frequently
219 | // But when we have no work to do it can consume considerable CPU time
220 | // So we only start this ticker when we actuall have work to do,
221 | // and we stop it the rest of the time.
222 | // We use a frame tick to ensure at least once every 16ms (60fps)
223 | // we are checking if we need to do work
224 | workTick.Stop()
225 | }
226 | if r.nextUnitOfWork != nil && !shouldYield {
227 | // start := time.Now()
228 | r.nextUnitOfWork = r.performWork(r.nextUnitOfWork)
229 | // d.Log("performWork: ", time.Since(start))
230 |
231 | // yield with time to render
232 | if time.Since(deadline) > 100*time.Nanosecond {
233 | shouldYield = true
234 | }
235 | }
236 |
237 | if r.nextUnitOfWork == nil && r.wipRoot != nil {
238 | start := time.Now()
239 | r.commitRoot()
240 | d.Log("commitRoot: ", time.Since(start))
241 | shouldYield = false
242 | }
243 |
244 | if time.Since(deadline) > 0 {
245 | shouldYield = false
246 | frame++
247 | }
248 | }
249 | }
250 | }()
251 |
252 | // Wait until quit
253 | <-quitChan
254 | screen.Clear()
255 | screen.Fini()
256 | }
257 |
258 | func (r *retort) parseRetortConfiguration() {
259 | screen := UseScreen()
260 |
261 | if !r.config.DisableMouse {
262 | screen.EnableMouse()
263 | }
264 | }
265 |
266 | // ForceRender can be called at any point to ask
267 | // retort to start a whole new update
268 | func (r *retort) ForceRender() {
269 | r.updateTree()
270 | }
271 |
272 | // [ Working ]------------------------------------------------------------------
273 |
274 | func (r *retort) updateTree() {
275 | r.wipRoot = r.currentRoot
276 | r.wipRoot.alternate = r.currentRoot.Clone()
277 | r.wipRoot.dirty = true
278 |
279 | r.nextUnitOfWork = r.wipRoot
280 | r.wipFiber = nil
281 | r.deletions = nil
282 | r.hasChangesToRender = true
283 | r.hasNewState = false
284 | }
285 |
286 | func (r *retort) performWork(f *fiber) *fiber {
287 | r.updateComponent(f)
288 |
289 | if f.child != nil {
290 | return f.child
291 | }
292 |
293 | nextFiber := f
294 |
295 | for nextFiber != nil {
296 | if nextFiber.sibling != nil {
297 | return nextFiber.sibling
298 | }
299 | nextFiber = nextFiber.parent
300 | }
301 |
302 | return nil
303 | }
304 |
305 | // [ Components ]---------------------------------------------------------------
306 |
307 | func (r *retort) updateComponent(f *fiber) {
308 | hookFiberLock.Lock()
309 | hookFiber = f
310 | hookFiberLock.Unlock()
311 |
312 | switch f.componentType {
313 | case nothingComponent:
314 | r.updateNothingComponent(f)
315 | case elementComponent:
316 | r.updateElementComponent(f)
317 | case fragmentComponent:
318 | r.updateFragmentComponent(f)
319 | case screenComponent:
320 | r.updateScreenComponent(f)
321 | }
322 |
323 | // d.Log("updateComponent", f)
324 | hookFiberLock.Lock()
325 | hookFiber = nil
326 | hookFiberLock.Unlock()
327 | }
328 |
329 | func (r *retort) updateElementComponent(f *fiber) {
330 | if f == nil || f.componentType != elementComponent {
331 | return
332 | }
333 |
334 | if f.component == nil || f.Properties == nil {
335 | return
336 | }
337 | r.wipFiber = f
338 |
339 | hookFiberLock.Lock()
340 | hookIndex = 0
341 | hookFiberLock.Unlock()
342 |
343 | r.wipFiber.hooks = nil
344 |
345 | children := f.component(f.Properties)
346 | // d.Log("updateElementComponent children", children)
347 | r.reconcileChildren(f, []*fiber{children})
348 | }
349 |
350 | func (r *retort) updateFragmentComponent(f *fiber) {
351 | if f == nil || f.componentType != fragmentComponent || f.Properties == nil {
352 | return
353 | }
354 |
355 | r.wipFiber = f
356 |
357 | children := f.Properties.GetProperty(
358 | Children{},
359 | "Fragment requires r.Children",
360 | ).(Children)
361 |
362 | r.reconcileChildren(f, children)
363 | }
364 |
365 | func (r *retort) updateNothingComponent(f *fiber) {
366 | if f == nil || f.componentType != nothingComponent {
367 | return
368 | }
369 |
370 | r.wipFiber = f
371 |
372 | children := f.Properties.GetOptionalProperty(
373 | Children{},
374 | ).(Children)
375 | r.reconcileChildren(f, children)
376 | }
377 |
378 | func (r *retort) updateScreenComponent(f *fiber) {
379 | if f == nil || f.componentType != screenComponent {
380 | return
381 | }
382 |
383 | r.wipFiber = f
384 |
385 | children := f.Properties.GetOptionalProperty(
386 | Children{},
387 | ).(Children)
388 |
389 | r.reconcileChildren(f, children)
390 | }
391 |
392 | // [ Children ]-----------------------------------------------------------------
393 |
394 | func (r *retort) reconcileChildren(f *fiber, elements []*fiber) {
395 | index := 0
396 |
397 | f.dirty = false
398 |
399 | var oldFiber *fiber
400 | if r.wipFiber != nil && r.wipFiber.alternate != nil {
401 | oldFiber = r.wipFiber.alternate.child
402 | }
403 |
404 | var prevSibling *fiber
405 |
406 | // Add newly generated child elements, as children to this fiber
407 | for index < len(elements) || oldFiber != nil {
408 | var element *fiber
409 | if len(elements) != 0 {
410 | element = elements[index]
411 | }
412 |
413 | var newFiber *fiber
414 |
415 | sameType := false
416 |
417 | if oldFiber != nil && element != nil &&
418 | reflect.TypeOf(element.component) == reflect.TypeOf(oldFiber.component) {
419 | sameType = true
420 | }
421 |
422 | if sameType { // Update
423 | f.dirty = true
424 | newFiber = &fiber{
425 | dirty: true,
426 | componentType: element.componentType,
427 | component: element.component,
428 | Properties: AddPropsIfNone(element.Properties, f.InnerBlockLayout),
429 | parent: f,
430 | alternate: oldFiber,
431 | effect: fiberEffectUpdate,
432 | renderToScreen: element.renderToScreen,
433 | calculateLayout: element.calculateLayout,
434 | BlockLayout: f.InnerBlockLayout,
435 | InnerBlockLayout: f.InnerBlockLayout,
436 | }
437 | }
438 |
439 | if element != nil && !sameType { // New Placement
440 | f.dirty = true
441 | newFiber = &fiber{
442 | dirty: true,
443 | componentType: element.componentType,
444 | component: element.component,
445 | Properties: AddPropsIfNone(element.Properties, f.InnerBlockLayout),
446 | parent: f,
447 | alternate: nil,
448 | effect: fiberEffectPlacement,
449 | renderToScreen: element.renderToScreen,
450 | calculateLayout: element.calculateLayout,
451 | BlockLayout: f.InnerBlockLayout,
452 | InnerBlockLayout: f.InnerBlockLayout,
453 | }
454 | }
455 |
456 | if oldFiber != nil && !sameType { // Delete
457 | oldFiber.effect = fiberEffectDelete
458 | r.deletions = append(r.deletions, oldFiber)
459 | }
460 |
461 | if oldFiber != nil { // nothing to update
462 | oldFiber = oldFiber.sibling
463 | }
464 |
465 | if index == 0 {
466 | f.dirty = true
467 | f.child = newFiber
468 | } else if element != nil {
469 | f.dirty = true
470 | prevSibling.sibling = newFiber
471 | }
472 |
473 | prevSibling = newFiber
474 | index++
475 | }
476 | }
477 |
--------------------------------------------------------------------------------
/r/useContext.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "sync"
5 | )
6 |
7 | type Context struct {
8 | defaultState State
9 | }
10 |
11 | func (c *Context) Mount(state State) {
12 | hookFiberLock.Lock()
13 |
14 | if hookFiber == nil {
15 | panic("UseContext was not called inside a Component.")
16 | }
17 | var oldHook *hook
18 |
19 | if hookFiber != nil &&
20 | hookFiber.alternate != nil &&
21 | hookFiber.alternate.hooks != nil &&
22 | len(hookFiber.alternate.hooks) > hookIndex &&
23 | hookFiber.alternate.hooks[hookIndex] != nil {
24 | oldHook = hookFiber.alternate.hooks[hookIndex]
25 | }
26 |
27 | var h *hook
28 | if oldHook != nil {
29 | h = oldHook
30 | // h.state = s ??
31 | } else {
32 | h = &hook{
33 | tag: hookTagContext,
34 | mutex: &sync.Mutex{},
35 | state: state,
36 | context: c,
37 | }
38 | }
39 |
40 | if hookFiber != nil {
41 | hookFiber.hooks = append(hookFiber.hooks, h)
42 | hookIndex++
43 | }
44 |
45 | hookFiberLock.Unlock()
46 | }
47 |
48 | // CreateContext allows you to create a Context that can be used with UseContext
49 | // It must be called from outside your Component.
50 | func CreateContext(initial State) *Context {
51 | if hookFiber != nil {
52 | panic("CreateContext was called inside a Component.")
53 | }
54 |
55 | context := &Context{
56 | defaultState: initial,
57 | }
58 |
59 | return context
60 | }
61 |
62 | // UseContext lets you subscribe to changes of Context without nesting.
63 | func UseContext(c *Context) State {
64 | hookFiberLock.Lock()
65 | // Walk the tree up via parents, and stop when we find a Provider
66 | // that matches our Context
67 | if hookFiber == nil {
68 | panic("UseContext was not called inside a Component.")
69 | }
70 |
71 | state := findContext(c, hookFiber)
72 |
73 | var oldHook *hook
74 |
75 | if hookFiber != nil &&
76 | hookFiber.alternate != nil &&
77 | hookFiber.alternate.hooks != nil &&
78 | len(hookFiber.alternate.hooks) > hookIndex &&
79 | hookFiber.alternate.hooks[hookIndex] != nil {
80 | oldHook = hookFiber.alternate.hooks[hookIndex]
81 | }
82 |
83 | var h *hook
84 | if oldHook != nil {
85 | h = oldHook
86 | h.state = state
87 | } else {
88 | h = &hook{
89 | tag: hookTagState,
90 | mutex: &sync.Mutex{},
91 | state: state,
92 | }
93 | }
94 |
95 | var actions []Action
96 | if oldHook != nil {
97 | actions = oldHook.queue
98 |
99 | for _, action := range actions {
100 | h.state = action(h.state)
101 | }
102 | }
103 |
104 | if hookFiber != nil {
105 | hookFiber.hooks = append(hookFiber.hooks, h)
106 | hookIndex++
107 | }
108 | // debug.Spew("useContext", h, state)
109 | hookFiberLock.Unlock()
110 | return state
111 | }
112 |
113 | func findContext(c *Context, f *fiber) State {
114 | if f == nil {
115 | return nil
116 | }
117 |
118 | foundContext := false
119 | var matchingHook *hook
120 |
121 | for _, h := range f.hooks {
122 | if h == nil {
123 | continue
124 | }
125 | if h.tag == hookTagContext && h.context == c {
126 | foundContext = true
127 | matchingHook = h
128 | }
129 | }
130 |
131 | if !foundContext || matchingHook == nil {
132 | if f.parent == nil {
133 | return nil
134 | }
135 | return findContext(c, f.parent)
136 | }
137 | if matchingHook == nil || len(matchingHook.state) != 1 {
138 | return nil
139 | }
140 |
141 | return matchingHook.state
142 | }
143 |
--------------------------------------------------------------------------------
/r/useEffect.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "reflect"
5 | )
6 |
7 | type (
8 | // Effect is the function type you pass to UseEffect.
9 | //
10 | // It must return an EffectCancel, even if there is nothing to clean up.
11 | //
12 | // In the Effect you can have a routine to do something (such as fetching
13 | // data), and then call SetState from a UseState hook, to update your
14 | // Component.
15 | Effect func() EffectCancel
16 | // EffectCancel is a function that must be returned by your Effect, and is
17 | // called when the effect is cleaned up or canceled. This allows you to finish
18 | // anything you were doing such as closing channels, connections or files.
19 | EffectCancel func()
20 | // EffectDependencies lets you pass in what your Effect depends upon.
21 | // If they change, your Effect will be re-run.
22 | EffectDependencies []interface{}
23 | )
24 |
25 | // UseEffect is a retort hook that can be called in your Component to run side
26 | // effects.
27 | //
28 | // Where UseState allows your components to re-render when their State changes,
29 | // UseEffect allows you to change that state when you need to.
30 | //
31 | // Data fetching is a good example of when you would want something like
32 | // UseEffect.
33 | //
34 | // Example
35 | //
36 | // The example below is a reasonably simple one that changes the color of the
37 | // border of a box ever 2 seconds. The point here is to show how you can run a
38 | // goroutine in the UseEffect callback, and clean up the channel in the
39 | // EffectCancel return function.
40 | //
41 | // import (
42 | // "time"
43 | //
44 | // "github.com/gdamore/tcell"
45 | // "retort.dev/component"
46 | // "retort.dev/r"
47 | // )
48 | //
49 | // type EffectExampleBoxState struct {
50 | // Color tcell.Color
51 | // }
52 | //
53 | // func EffectExampleBox(p r.Properties) r.Element {
54 | // boxProps := p.GetProperty(
55 | // component.BoxProps{},
56 | // "Container requires ContainerProps",
57 | // ).(component.BoxProps)
58 | //
59 | // children := p.GetProperty(
60 | // r.Children{},
61 | // "Container requires r.Children",
62 | // ).(r.Children)
63 | //
64 | // s, setState := r.UseState(r.State{
65 | // EffectExampleBoxState{Color: boxProps.Border.Foreground},
66 | // })
67 | // state := s.GetState(
68 | // EffectExampleBoxState{},
69 | // ).(EffectExampleBoxState)
70 | //
71 | // r.UseEffect(func() r.EffectCancel {
72 | // ticker := time.NewTicker(2 * time.Second)
73 | //
74 | // done := make(chan bool)
75 | //
76 | // go func() {
77 | // for {
78 | // select {
79 | // case <-done:
80 | // return
81 | // case <-ticker.C:
82 | // setState(func(s r.State) r.State {
83 | // ms := s.GetState(
84 | // EffectExampleBoxState{},
85 | // ).(EffectExampleBoxState)
86 | //
87 | // color := tcell.ColorGreen
88 | // if ms.Color == tcell.ColorGreen {
89 | // color = tcell.ColorBlue
90 | // }
91 | //
92 | // if ms.Color == tcell.ColorBlue {
93 | // color = tcell.ColorGreen
94 | // }
95 | //
96 | // return r.State{EffectExampleBoxState{
97 | // Color: color,
98 | // },
99 | // }
100 | // })
101 | // }
102 | // }
103 | // }()
104 | // return func() {
105 | // <-done
106 | // }
107 | // }, r.EffectDependencies{})
108 | //
109 | // // var mouseEventHandler r.MouseEventHandler
110 | // mouseEventHandler := func(e *tcell.EventMouse) {
111 | // color := tcell.ColorGreen
112 | // if state.Color == tcell.ColorGreen {
113 | // color = tcell.ColorBlue
114 | // }
115 | //
116 | // if state.Color == tcell.ColorBlue {
117 | // color = tcell.ColorGreen
118 | // }
119 | //
120 | // setState(func(s r.State) r.State {
121 | // return r.State{EffectExampleBoxState{
122 | // Color: color,
123 | // },
124 | // }
125 | // })
126 | // }
127 | //
128 | // boxProps.Border.Foreground = state.Color
129 | //
130 | // return r.CreateElement(
131 | // component.Box,
132 | // r.Properties{
133 | // boxProps,
134 | // mouseEventHandler,
135 | // },
136 | // children,
137 | // )
138 | // }
139 | func UseEffect(effect Effect, deps EffectDependencies) {
140 | hookFiberLock.Lock()
141 |
142 | var oldHook *hook
143 | if hookFiber != nil &&
144 | hookFiber.alternate != nil &&
145 | hookFiber.alternate.hooks != nil &&
146 | len(hookFiber.alternate.hooks) > hookIndex &&
147 | hookFiber.alternate.hooks[hookIndex] != nil {
148 | oldHook = hookFiber.alternate.hooks[hookIndex]
149 | }
150 |
151 | hasChanged := true
152 |
153 | if oldHook != nil {
154 | hasChanged = hasDepsChanged(oldHook.deps, deps)
155 | }
156 |
157 | h := &hook{
158 | tag: hookTagEffect,
159 | effect: nil,
160 | cancel: nil,
161 | }
162 |
163 | if hasChanged {
164 | h.effect = effect
165 | h.deps = deps
166 | }
167 |
168 | if hookFiber != nil {
169 | hookFiber.hooks = append(hookFiber.hooks, h)
170 | hookIndex++
171 | }
172 | hookFiberLock.Unlock()
173 | }
174 |
175 | func hasDepsChanged(
176 | prevDeps,
177 | nextDeps EffectDependencies,
178 | ) (changed bool) {
179 | if !reflect.DeepEqual(prevDeps, nextDeps) {
180 | changed = true
181 | }
182 |
183 | if len(prevDeps) == 0 {
184 | changed = false
185 | }
186 | if len(nextDeps) == 0 {
187 | changed = false
188 | }
189 |
190 | // Check the slices have the same contents, in the same order
191 | // for i, pd := range prevDeps {
192 | // if nextDeps[i] != pd {
193 | // changed = true
194 | // }
195 | // }
196 |
197 | return
198 | }
199 |
200 | func (r *retort) processEffects(f *fiber) {
201 | runEffects := false
202 | cancelEffects := false
203 |
204 | if f == nil {
205 | return
206 | }
207 |
208 | switch f.effect {
209 | case fiberEffectNothing:
210 | runEffects = true
211 | case fiberEffectPlacement:
212 | runEffects = true
213 | case fiberEffectUpdate:
214 | runEffects = true
215 | case fiberEffectDelete:
216 | cancelEffects = true
217 | }
218 |
219 | if f.hooks != nil {
220 | if cancelEffects {
221 | for _, h := range f.hooks {
222 | if h.tag != hookTagEffect ||
223 | h.effect == nil {
224 | continue
225 | }
226 | h.cancel()
227 | }
228 | }
229 |
230 | if runEffects {
231 | for _, h := range f.hooks {
232 | if h.tag != hookTagEffect ||
233 | h.effect == nil {
234 | continue
235 | }
236 | h.cancel = h.effect()
237 | }
238 | }
239 | }
240 |
241 | r.processEffects(f.child)
242 | r.processEffects(f.sibling)
243 | }
244 |
--------------------------------------------------------------------------------
/r/useQuit.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | // UseQuit returns a single function that when invoked
4 | // will exit the application
5 | func UseQuit() func() {
6 | return func() {
7 | close(quitChan)
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/r/useScreen.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/gdamore/tcell"
8 | "github.com/gdamore/tcell/encoding"
9 | )
10 |
11 | // UseScreen returns a tcell.Screen allowing you to read and
12 | // interact with the Screen directly.
13 | //
14 | // Even though this means you can modify the Screen from
15 | // anywhere, just as you should avoid DOM manipulation directly
16 | // in React, you should avoid manipulating the Screen with
17 | // this hook.
18 | //
19 | // Use this hook to read information from the screen only.
20 | //
21 | // If you need to write to the Screen, use a ScreenElement.
22 | // This ensures when your Component has changes, retort will
23 | // call your DisplayCommand function. Doing this any other way
24 | // will gaurentee at some point things will get out of sync.
25 | func UseScreen() tcell.Screen {
26 | if hasScreenInstance {
27 | return useScreenInstance
28 | }
29 |
30 | var s tcell.Screen
31 | var err error
32 |
33 | if c.UseSimulationScreen {
34 | s = tcell.NewSimulationScreen("UTF-8")
35 | } else {
36 | s, err = tcell.NewScreen()
37 | }
38 | useScreenInstance = s
39 | encoding.Register()
40 | if err != nil {
41 | fmt.Fprintf(os.Stderr, "%v\n", err)
42 | os.Exit(1)
43 | }
44 |
45 | hasScreenInstance = true
46 | return useScreenInstance
47 | }
48 |
--------------------------------------------------------------------------------
/r/useState.go:
--------------------------------------------------------------------------------
1 | package r
2 |
3 | import (
4 | "reflect"
5 | "sync"
6 | )
7 |
8 | type (
9 | Action = func(s State) State
10 | ActionCreator struct {
11 | h *hook
12 | a Action
13 | }
14 | SetState = func(a Action)
15 | )
16 |
17 | // UseState provides local state for a Component.
18 | //
19 | // With UseState you can make your components interactive,
20 | // and repsonsive to either user input or anything else that
21 | // changes.
22 | //
23 | // UseState by itself only gives you the ability to change state,
24 | // you generally need to pair this with either an EventHandler
25 | // or UseEffect to provide interactivity.
26 | //
27 | // Don't call setState inside your Component, as this will create
28 | // an infinite rendering loop.
29 | //
30 | // Example
31 | //
32 | // The following example shows how you can use state to change
33 | // the color of a Box border when it's clicked.
34 | //
35 | // import (
36 | // "github.com/gdamore/tcell"
37 | // "retort.dev/component"
38 | // "retort.dev/r/debug"
39 | // "retort.dev/r"
40 | // )
41 | //
42 | // type MovingBoxState struct {
43 | // Color tcell.Color
44 | // }
45 | //
46 | // func ClickableBox(p r.Properties) r.Element {
47 | // boxProps := p.GetProperty(
48 | // component.BoxProps{},
49 | // "Container requires ContainerProps",
50 | // ).(component.BoxProps)
51 | //
52 | // children := p.GetProperty(
53 | // r.Children{},
54 | // "Container requires r.Children",
55 | // ).(r.Children)
56 | //
57 | // s, setState := r.UseState(r.State{
58 | // MovingBoxState{Color: boxProps.Border.Foreground},
59 | // })
60 | // state := s.GetState(
61 | // MovingBoxState{},
62 | // ).(MovingBoxState)
63 | //
64 | // mouseEventHandler := func(e *tcell.EventMouse) {
65 | // debug.Log("mouseEventHandler", e, state)
66 | // color := tcell.ColorGreen
67 | // if state.Color == tcell.ColorGreen {
68 | // color = tcell.ColorBlue
69 | // }
70 | //
71 | // if state.Color == tcell.ColorBlue {
72 | // color = tcell.ColorGreen
73 | // }
74 | //
75 | // setState(func(s r.State) r.State {
76 | // debug.Log("mouseEventHandler update state", e, state)
77 | //
78 | // return r.State{MovingBoxState{
79 | // Color: color,
80 | // },
81 | // }
82 | // })
83 | // }
84 | //
85 | // boxProps.Border.Foreground = state.Color
86 | //
87 | // return r.CreateElement(
88 | // component.Box,
89 | // r.Properties{
90 | // boxProps,
91 | // mouseEventHandler,
92 | // },
93 | // children,
94 | // )
95 | // }
96 | func UseState(initial State) (State, SetState) {
97 | hookFiberLock.Lock()
98 | checkStateTypesAreUnique(initial)
99 | var oldHook *hook
100 |
101 | if hookFiber != nil &&
102 | hookFiber.alternate != nil &&
103 | hookFiber.alternate.hooks != nil &&
104 | len(hookFiber.alternate.hooks) > hookIndex &&
105 | hookFiber.alternate.hooks[hookIndex] != nil {
106 | oldHook = hookFiber.alternate.hooks[hookIndex]
107 | }
108 |
109 | var h *hook
110 | if oldHook != nil {
111 | h = oldHook
112 | } else {
113 | h = &hook{
114 | tag: hookTagState,
115 | mutex: &sync.Mutex{},
116 | state: initial,
117 | }
118 | }
119 |
120 | var actions []Action
121 | if oldHook != nil {
122 | actions = oldHook.queue
123 |
124 | for _, action := range actions {
125 | h.state = action(h.state)
126 | }
127 | }
128 |
129 | if hookFiber != nil {
130 | hookFiber.hooks = append(hookFiber.hooks, h)
131 | hookIndex++
132 | }
133 |
134 | hookFiberLock.Unlock()
135 |
136 | return h.state, h.setState()
137 | }
138 |
139 | func (h *hook) setState() SetState {
140 | return func(a Action) {
141 | setStateChan <- ActionCreator{
142 | h: h,
143 | a: a,
144 | }
145 | }
146 | }
147 |
148 | func (ac ActionCreator) addToQueue() {
149 | ac.h.mutex.Lock()
150 | if ac.h.mutex == nil {
151 | panic("h is gone")
152 | }
153 |
154 | ac.h.queue = append(ac.h.queue, ac.a)
155 | ac.h.mutex.Unlock()
156 |
157 | }
158 |
159 | func checkStateTypesAreUnique(s State) bool {
160 | seenPropTypes := make(map[reflect.Type]bool)
161 |
162 | for _, p := range s {
163 | if seen := seenPropTypes[reflect.TypeOf(p)]; seen {
164 | return false
165 | }
166 | seenPropTypes[reflect.TypeOf(p)] = true
167 | }
168 | return true
169 | }
170 |
171 | // GetState lets you retrieve the state of your passed in type from a UseState
172 | // hook.
173 | //
174 | // Because we cannot use generics this is the closest we can get. This is like
175 | // Properties where the stateType type is a key to the struct in the slice of
176 | // interfaces.
177 | // As such, you can only have one of a given type in state.
178 | func (state State) GetState(stateType interface{}) interface{} {
179 | for _, p := range state {
180 | if reflect.TypeOf(p) == reflect.TypeOf(stateType) {
181 | return p
182 | }
183 | }
184 | return stateType
185 | }
186 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Retort
2 |
3 | [](https://app.netlify.com/sites/retort/deploys)
4 | [](https://godoc.org/retort.dev/)
5 |
6 | A reactive terminal user interface library for golang, built ontop of tcell.
7 |
8 | Full documentation is available at https://godoc.org/retort.dev
9 |
10 | ## Current Status
11 |
12 | Retort is stil in development. It's layout engine is the last major piece to finish.
13 |
14 |
15 | ## Developing
16 |
17 | To run the example app:
18 |
19 | ```
20 | make dev
21 | ```
22 |
--------------------------------------------------------------------------------
/redirect.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 | retort.dev
13 |
14 |
15 |
--------------------------------------------------------------------------------
/todo.md:
--------------------------------------------------------------------------------
1 | This to do before this is releasable.
2 |
3 | # Box
4 |
5 | - [] TODO: Add title and footer text
6 | - [] TODO: Add ability to set content
7 | - [] TODO: Add layout engine
8 |
9 | # Components
10 |
11 | - [] TODO: Add input component
12 | - [] TODO: Add keyboard shortcut component
13 |
14 | # Events
15 |
16 | - [] TODO: Add mouse hover event
17 | - [] TODO: Add input events
18 | - [] TODO: Explore using the DisplayList from the last render with its order
19 | to locate mouse events
20 |
21 | # Debugging
22 |
23 | - [] TODO: Add debugger
24 | - [ ] TODO: make it take up the right 30% (or so) and shrink the screen size
25 | given to the other components
26 | (it could be a component you wrap your app in or something)
27 | - [] TODO: Add error log to debugger
28 | - [] TODO: some kind of error boundary component?
29 |
30 | # API
31 |
32 | - goal is to clean up the public api as much as possible
33 |
34 | # Rendering
35 |
36 | - [] TODO: keep track of Z indexs, and render each Zindex on its own layer
37 | - (ie sequentially, from bottom up)
38 |
--------------------------------------------------------------------------------