├── .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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 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 |
26 | 37 | Logo 38 |

39 | Retort 40 |

41 | Work In Progress 42 |

A Go library for building terminal user interfaces

43 |
44 |

45 | Get Started Example App 49 |

50 |
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 | [![Netlify Status](https://api.netlify.com/api/v1/badges/551ded08-7737-4e41-9093-40fd36828ccb/deploy-status)](https://app.netlify.com/sites/retort/deploys) 4 | [![GoDoc](https://godoc.org/retort.dev/?status.svg)](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 | --------------------------------------------------------------------------------