├── .eslintrc.js ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── AUTHORS ├── LICENSE ├── README.md ├── bsconfig.example.json ├── bsconfig ├── bsconfig.dev.json ├── bsconfig.lint.json ├── bsconfig.test.json ├── bsfmt.jsonc ├── bslint.jsonc └── plugins │ └── testAnnotations.ts ├── package-lock.json ├── package.json ├── src ├── components │ ├── SGFlex.bs │ └── SGFlex.xml ├── manifest ├── source │ └── SGFlexModel.bs └── tests │ ├── constructor.test.bs │ ├── csswg │ └── 8--alignment │ │ ├── 8-2--justify-content.test.bs │ │ └── 8-3--align-items.test.bs │ └── others │ └── justify-content.test.bs ├── test-project ├── package-lock.json ├── package.json └── src │ ├── components │ ├── TestScene.brs │ └── TestScene.xml │ ├── manifest │ └── source │ └── main.brs └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | env: { 5 | node: true, 6 | mocha: true, 7 | es6: true 8 | }, 9 | parserOptions: { 10 | project: ['./tsconfig.json'], 11 | createDefaultProgram: true 12 | }, 13 | plugins: [ 14 | '@typescript-eslint', 15 | 'no-only-tests', 16 | 'github' 17 | ], 18 | extends: [ 19 | 'eslint:all', 20 | 'plugin:@typescript-eslint/all' 21 | ], 22 | rules: { 23 | '@typescript-eslint/array-type': 'off', 24 | '@typescript-eslint/consistent-type-assertions': 'off', 25 | '@typescript-eslint/explicit-function-return-type': 'off', 26 | '@typescript-eslint/explicit-member-accessibility': 'off', 27 | '@typescript-eslint/explicit-module-boundary-types': 'off', 28 | '@typescript-eslint/init-declarations': 'off', 29 | '@typescript-eslint/lines-between-class-members': 'off', 30 | '@typescript-eslint/member-ordering': 'off', 31 | '@typescript-eslint/method-signature-style': 'off', 32 | '@typescript-eslint/naming-convention': 'off', 33 | '@typescript-eslint/no-base-to-string': 'off', 34 | '@typescript-eslint/no-confusing-void-expression': 'off', 35 | '@typescript-eslint/no-dynamic-delete': 'off', 36 | '@typescript-eslint/no-empty-function': 'off', 37 | '@typescript-eslint/no-explicit-any': 'off', 38 | '@typescript-eslint/no-extra-parens': 'off', 39 | '@typescript-eslint/no-floating-promises': 'error', 40 | '@typescript-eslint/no-implicit-any-catch': 'off', 41 | '@typescript-eslint/no-invalid-this': 'off', 42 | '@typescript-eslint/no-magic-numbers': 'off', 43 | '@typescript-eslint/no-parameter-properties': 'off', 44 | //had to add this rule to prevent eslint from crashing 45 | '@typescript-eslint/no-restricted-imports': ['off', {}], 46 | //mitigating this sometimes results in undesirably verbose code. Should investigate enabling again in the future. 47 | '@typescript-eslint/no-unsafe-argument': 'off', 48 | 'object-curly-spacing': 'off', 49 | '@typescript-eslint/object-curly-spacing': [ 50 | 'error', 51 | 'always' 52 | ], 53 | '@typescript-eslint/no-shadow': 'off', 54 | '@typescript-eslint/no-this-alias': 'off', 55 | //possibly disable this once we have converted all throw statements to actual errors 56 | '@typescript-eslint/no-throw-literal': 'off', 57 | '@typescript-eslint/no-invalid-void': 'off', 58 | '@typescript-eslint/no-invalid-void-type': 'off', 59 | '@typescript-eslint/no-type-alias': 'off', 60 | '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', 61 | '@typescript-eslint/no-unnecessary-condition': 'off', 62 | '@typescript-eslint/no-unsafe-assignment': 'off', 63 | '@typescript-eslint/no-unsafe-call': 'off', 64 | '@typescript-eslint/no-unsafe-member-access': 'off', 65 | '@typescript-eslint/no-unsafe-return': 'off', 66 | '@typescript-eslint/no-unused-vars': 'off', 67 | '@typescript-eslint/no-unused-vars-experimental': 'off', 68 | '@typescript-eslint/no-use-before-define': 'off', 69 | '@typescript-eslint/prefer-for-of': 'off', 70 | '@typescript-eslint/prefer-readonly': 'off', 71 | '@typescript-eslint/prefer-readonly-parameter-types': 'off', 72 | '@typescript-eslint/promise-function-async': 'off', 73 | '@typescript-eslint/quotes': [ 74 | 'error', 75 | 'single', 76 | { 77 | 'allowTemplateLiterals': true 78 | } 79 | ], 80 | '@typescript-eslint/require-array-sort-compare': 'off', 81 | '@typescript-eslint/restrict-plus-operands': 'off', 82 | '@typescript-eslint/restrict-template-expressions': 'off', 83 | '@typescript-eslint/sort-type-union-intersection-members': 'off', 84 | '@typescript-eslint/space-before-function-paren': 'off', 85 | '@typescript-eslint/strict-boolean-expressions': 'off', 86 | '@typescript-eslint/typedef': 'off', 87 | '@typescript-eslint/unbound-method': 'off', 88 | '@typescript-eslint/unified-signatures': 'off', 89 | 'array-bracket-newline': 'off', 90 | 'array-element-newline': 'off', 91 | 'array-type': 'off', 92 | 'arrow-body-style': 'off', 93 | 'arrow-parens': 'off', 94 | 'callback-return': 'off', 95 | 'capitalized-comments': 'off', 96 | 'class-methods-use-this': 'off', 97 | 'complexity': 'off', 98 | 'consistent-return': 'off', 99 | 'consistent-this': 'off', 100 | 'curly': 'error', 101 | 'default-case': 'off', 102 | 'dot-location': 'off', 103 | 'dot-notation': 'off', 104 | 'func-style': 'off', 105 | 'function-call-argument-newline': 'off', 106 | 'function-paren-newline': 'off', 107 | 'getter-return': 'off', 108 | 'github/array-foreach': 'error', 109 | 'guard-for-in': 'off', 110 | 'id-length': 'off', 111 | 'indent': 'off', 112 | 'init-declarations': 'off', 113 | 'line-comment-position': 'off', 114 | 'linebreak-style': 'off', 115 | 'lines-around-comment': 'off', 116 | 'lines-between-class-members': 'off', 117 | 'max-classes-per-file': 'off', 118 | 'max-depth': 'off', 119 | 'max-len': 'off', 120 | 'max-lines': 'off', 121 | 'max-lines-per-function': 'off', 122 | 'max-params': 'off', 123 | 'max-statements': 'off', 124 | 'no-only-tests/no-only-tests': 'error', 125 | 'multiline-comment-style': 'off', 126 | 'multiline-ternary': 'off', 127 | 'new-cap': 'off', 128 | 'newline-per-chained-call': 'off', 129 | 'no-await-in-loop': 'off', 130 | 'no-case-declarations': 'off', 131 | 'no-constant-condition': 'off', 132 | 'no-console': 'off', 133 | 'no-continue': 'off', 134 | 'no-else-return': 'off', 135 | 'no-empty': 'off', 136 | 'no-implicit-coercion': 'off', 137 | 'no-inline-comments': 'off', 138 | 'no-invalid-this': 'off', 139 | 'no-labels': 'off', 140 | 'no-lonely-if': 'off', 141 | 'no-negated-condition': 'off', 142 | 'no-param-reassign': 'off', 143 | 'no-plusplus': 'off', 144 | 'no-process-exit': 'off', 145 | 'no-prototype-builtins': 'off', 146 | 'no-shadow': 'off', 147 | 'no-sync': 'off', 148 | 'no-ternary': 'off', 149 | 'no-undefined': 'off', 150 | 'no-underscore-dangle': 'off', 151 | 'no-unneeded-ternary': 'off', 152 | 'no-useless-escape': 'off', 153 | 'no-warning-comments': 'off', 154 | 'object-property-newline': 'off', 155 | 'object-shorthand': [ 156 | 'error', 157 | 'never' 158 | ], 159 | 'one-var': [ 160 | 'error', 161 | 'never' 162 | ], 163 | 'padded-blocks': 'off', 164 | 'prefer-const': 'off', 165 | 'prefer-destructuring': 'off', 166 | 'prefer-named-capture-group': 'off', 167 | 'prefer-template': 'off', 168 | 'quote-props': 'off', 169 | 'radix': 'off', 170 | 'require-atomic-updates': 'off', 171 | 'require-unicode-regexp': 'off', 172 | 'sort-imports': 'off', 173 | 'sort-keys': 'off', 174 | 'spaced-comment': 'off', 175 | 'vars-on-top': 'off', 176 | 'wrap-regex': 'off' 177 | }, 178 | //disable some rules for certain files 179 | overrides: [{ 180 | //these files are getting deleted soon, so ingore the eslint warnings for now 181 | files: ['src/brsTypes/**/*.ts'], 182 | rules: { 183 | '@typescript-eslint/no-invalid-this': 'off', 184 | '@typescript-eslint/method-signature-style': 'off', 185 | '@typescript-eslint/no-unsafe-assignment': 'off', 186 | '@typescript-eslint/prefer-enum-initializers': 'off' 187 | } 188 | }, 189 | { 190 | files: ['*.spec.ts'], 191 | rules: { 192 | '@typescript-eslint/no-unsafe-assignment': 'off', 193 | '@typescript-eslint/no-unsafe-call': 'off', 194 | '@typescript-eslint/no-unsafe-member-access': 'off', 195 | '@typescript-eslint/no-unsafe-return': 'off', 196 | '@typescript-eslint/no-unused-expressions': 'off', 197 | '@typescript-eslint/no-unused-vars': 'off', 198 | '@typescript-eslint/no-unused-vars-experimental': 'off', 199 | '@typescript-eslint/dot-notation': 'off', 200 | '@typescript-eslint/no-unsafe-argument': 'off', 201 | 'github/array-foreach': 'off', 202 | 'new-cap': 'off', 203 | 'no-shadow': 'off', 204 | 'no-void': 'off' 205 | } 206 | }, { 207 | files: ['benchmarks/**/*'], 208 | rules: { 209 | '@typescript-eslint/no-require-imports': 'off', 210 | '@typescript-eslint/no-var-requires': 'off' 211 | } 212 | }] 213 | }; 214 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | out/ 3 | dist/ 4 | bsconfig.json 5 | roku_modules -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "enableDebuggerAutoRecovery": false, 6 | "name": "Launch Test App", 7 | "preLaunchTask": "build", 8 | "request": "launch", 9 | "rootDir": "${workspaceFolder}/test-project/src", 10 | "stopOnEntry": false, 11 | "type": "brightscript", 12 | "injectRdbOnDeviceComponent": true 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "node_modules": true 4 | }, 5 | "brightscript.bsdk": "embedded" 6 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "presentation": { 7 | "close": true, 8 | "reveal": "silent", 9 | "revealProblems": "onProblem" 10 | }, 11 | "script": "build:test-project", 12 | "type": "npm" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the list of sg-flex's significant contributors. 2 | # 3 | # To see the full list of contributors, see the revision history in 4 | # source control. 5 | 6 | Arturo Cuya 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Haystack TV Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sg-flex 2 | 3 | A Flexbox node for Roku's SceneGraph. 4 | 5 | - [Install](#install) 6 | * [With ropm](#with-ropm) 7 | * [Manual install](#manual-install) 8 | - [Usage](#usage) 9 | * [Directly in your XML](#directly-in-your-xml) 10 | * [Or programatically](#or-programatically) 11 | - [API](#api) 12 | - [Current Limitations](#current-limitations) 13 | - [Running the test project](#running-the-test-project) 14 | * [Option 1: Sideload the application](#option-1-sideload-the-application) 15 | * [Option 2: Use VSCode](#option-2-use-vscode) 16 | - [Questions and suggestions](#questions-and-suggestions) 17 | - [Contributing](#contributing) 18 | 19 | ## Install 20 | 21 | ### With [ropm](https://github.com/rokucommunity/ropm) 22 | 23 | ``` 24 | (tbd) 25 | ``` 26 | 27 | ### Manual install 28 | 29 | Grab our latest release from our [Releases](https://github.com/haystacknews/sg-flex/releases) page and copy the contents of the `components` and `source` folders into the root of the equivalent folders in your application. Remember to update the import scripts if you copy them anywhere else. 30 | 31 | ## Usage 32 | 33 | ### Directly in your XML 34 | 35 | ```xml 36 | 45 | 46 | 48 | 49 | 51 | 52 | 54 | 55 | ``` 56 | 57 | ### Or programatically 58 | 59 | ```brs 60 | m.sgFlex = CreateObject("roSGNode", "SGFlex") 61 | 62 | ' Consider that `flexify()` will be called for each of these assignments. 63 | m.sgFlex.width = 1790 64 | m.sgFlex.height = 596 65 | m.sgFlex.direction = "row" 66 | m.sgFlex.justifyContent = "spaceBetween" 67 | m.sgFlex.alignItems = "center" 68 | 69 | r1 = CreateObject("roSGNode", "Rectangle") 70 | r1.width = 170 71 | r1.height = 170 72 | 73 | r2 = r1.clone() 74 | r3 = r1.clone 75 | 76 | m.sgFlex.appendChild(r1) 77 | m.sgFlex.appendChild(r2) 78 | m.sgFlex.appendChild(r3) 79 | 80 | ' Call flexify again due to limitation #1 81 | m.sgFlex.callFunc("flexify") 82 | ``` 83 | 84 | ## API 85 | 86 | - `width` and `height`: Dimensions of the flex container. If missing, they will be replaced by the accumulated width and height of the children. 87 | 88 | - `direction`: The direction of the main axis. 89 | - `row` (default) 90 | - `column` 91 | 92 | - `justifyContent`: The flex alignment of the main axis. 93 | - `flexStart` (default) 94 | - `flexEnd` 95 | - `center` 96 | - `spaceBetween` 97 | - `spaceAround` 98 | - `spaceEvenly` 99 | 100 | - `alignItems`: 101 | - `flexStart` (default) 102 | - `flexEnd` 103 | - `center` 104 | 105 | - `flexifyOnce`: A boolean value indicating if the `SGFlex` node should only call `flexify()` once. If true, the node will ignore all the function calls to `flexify()` except the first one. 106 | 107 | - `flexify()`: Call this function every time you want to re calculate and apply the children alignments. 108 | 109 | ## Current Limitations 110 | 111 | - The `SGFlex` node is not reactive to changes to its children. To update the layout you need to call the `flexify` function. 112 | 113 | - The `SGFlex` node assumes its children have their coordinate center at the top left. Otherwise they will not be properly aligned. 114 | - For example `LayoutGroup` nodes with values other than `horizAlignment="left"` and `vertAlignment="top"` will not be laid out properly. Same for `Label` nodes with values other than `horizAlign="left"` and `vertAlign="top"`. 115 | - Consider replacing `LayoutGroup` nodes with `SGFlex` nodes to aleviate this. 116 | 117 | - There are no flexbox-like capabilities for the children (e.g: `order` or `alignSelf`). 118 | 119 | ## Running the test project 120 | 121 | ![image](https://user-images.githubusercontent.com/29876959/189559254-212b97e8-1c4d-4c59-9745-03dd7c5882cc.png) 122 | 123 | 124 | ### Option 1: Sideload the application 125 | 126 | Grab the `test-project.zip` file from our latest release on the [Releases](https://github.com/haystacknews/sg-flex/releases) page and sideload it into your Roku device following [these instructions](https://www.howtogeek.com/290787/how-to-enable-developer-mode-and-sideload-roku-apps/). 127 | 128 | ### Option 2: Use VSCode 129 | 130 | 1. Make sure you have the [BrightScript Language extension](https://marketplace.visualstudio.com/items?itemName=RokuCommunity.brightscript) installed. 131 | 132 | > Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter. 133 | 134 | ``` 135 | ext install RokuCommunity.brightscript 136 | ``` 137 | 138 | 2. Clone this repository 139 | 140 | ```bash 141 | git clone https://github.com/haystacknews/sg-flex.git 142 | ``` 143 | 144 | 3. Install the project dependencies (preferibly with Node `>=16.14.2` and NPM `>=8.5.0`) 145 | 146 | ```bash 147 | npm install 148 | ``` 149 | 150 | 3. Go to Run and Debug on the Activity Bar and `Start debugging` with the option `Launch Test App`. 151 | 152 | ![image](https://user-images.githubusercontent.com/29876959/189559098-15e6e326-64a9-40ef-bb7d-70ff9e7c86df.png) 153 | 154 | > The `preLaunchTask` might take a bit to load because it copies the file structure of the test project into a separate folder and runs `ropm install` every time. 155 | 156 | ## Questions and suggestions 157 | 158 | Hi! I'm Arturo Cuya, the main contributor of this project. If you have a question on how to use `sg-flex` or a suggesion on how to improve it, consider one of the following communication channels: 159 | 160 | 1. Create an issue explaining your question or asking for a feature; or 161 | 2. Ping me on the [Roku Developers Slack](https://rokudevelopers.slack.com/) 162 | 163 | ## Contributing 164 | 165 | > todo: explain this nicely 166 | 167 | - Remember to fork this repo and making a PR from yours into this instead of pulling this repo and doing modifications directly. 168 | 169 | - Use this node environment 170 | 171 | ```json 172 | "engines": { 173 | "node": ">=16.14.2", 174 | "npm": ">=8.5.0" 175 | }, 176 | ``` 177 | 178 | - Use BrighterScript 179 | 180 | - Switch between developing the component and the test project by changing the `rootDir` in `bsconfig.json` 181 | 182 | - Testing is done with `roca` and `brs`, using a BrighterScript plugin located in `bsconfig/plugins` to disable features not supported by `brs`. Run the tests with `npm run test` 183 | -------------------------------------------------------------------------------- /bsconfig.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "bsconfig/bsconfig.dev.json", 3 | "host": "", 4 | "password": "", 5 | "rootDir": "src" 6 | } -------------------------------------------------------------------------------- /bsconfig/bsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "autoImportComponentScript": true, 3 | "diagnosticFilters": [ 4 | { 5 | "codes": [ 6 | 1013 7 | ], 8 | "src": "tests/**/*.bs" 9 | }, 10 | { 11 | "codes": [ 12 | 1107 13 | ], 14 | "src": "**/roku_modules/**/*" 15 | }, 16 | { 17 | "codes": [ 18 | 1107 19 | ], 20 | "src": "components/TestScene.xml" 21 | } 22 | ], 23 | "files": [ 24 | "manifest", 25 | "source/**/*.*", 26 | "components/**/*.*", 27 | "images/**/*.*", 28 | "!tests/**/*.*" 29 | ], 30 | "lintConfig": "bsconfig/bslint.jsonc", 31 | "plugins": [ 32 | "@rokucommunity/bslint" 33 | ], 34 | "retainStagingFolder": true, 35 | "rootDir": "../src/", 36 | "stagingFolderPath": "dist/roku-deploy-staging", 37 | "username": "rokudev", 38 | "require": ["ts-node/register"] 39 | } -------------------------------------------------------------------------------- /bsconfig/bsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "createPackage": false, 3 | "deploy": false, 4 | "extends": "bsconfig.dev.json", 5 | "retainStagingFolder": false 6 | } -------------------------------------------------------------------------------- /bsconfig/bsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "bsconfig.dev.json", 3 | "files": [ 4 | "manifest", 5 | "source/**/*.*", 6 | "components/**/*.*", 7 | "images/**/*.*", 8 | "tests/**/*.*" 9 | ], 10 | "plugins": [ 11 | "@rokucommunity/bslint", 12 | "./plugins/testAnnotations.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /bsconfig/bsfmt.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | // The type of whitespace to use when indenting the beginning of lines. 3 | "indentStyle": "spaces", 4 | // The number of spaces to use when `indentStyle` is "spaces". 5 | "indentSpaceCount": 4, 6 | // Enforces all keywords and types to be lowercase. 7 | "keywordCase": "lower", 8 | // Forces all composite keywords (i.e. `elseif`, `endwhile`, etc...) to be consistent. 9 | // If "split", they are split into their alternatives (`else if`, `end while`). 10 | "compositeKeywords": "split", 11 | // Remove trailing whitespace at the end of each line. 12 | "removeTrailingWhiteSpace": true, 13 | // Ensure exactly 1 space after leading and before trailing curly braces. 14 | "insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": true, 15 | // Ensure exactly 1 space between an associative array literal key and its colon. 16 | "insertSpaceBetweenAssociativeArrayLiteralKeyAndColon": false, 17 | "formatSingleLineCommentType": "original", 18 | // For multi-line objects and arrays, move everything after 19 | // the `{` or `[` and everything before the `}` or `]` onto a new line. 20 | "formatMultiLineObjectsAndArrays": true 21 | } -------------------------------------------------------------------------------- /bsconfig/bslint.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | /* Code style rules */ 4 | // Do not allow inline `if` statements. 5 | "inline-if-style": "never", 6 | // Always use `then` in block `if` statements. 7 | "block-if-style": "no-then", 8 | // Always group `if` statement conditions with parenthesis. 9 | "condition-style": "group", 10 | // Use `sub` for `Void` functions. Otherwise, use `function`. 11 | // For both named and anonymous functions. 12 | "named-function-style": "auto", 13 | "anon-function-style": "auto", 14 | // Enforce presence of commas in associate array literals without 15 | // leaving one dangling. 16 | "aa-comma-style": "no-dangling", 17 | // Allow `print` statements. 18 | "no-print": "off", 19 | 20 | /* Strictness rules */ 21 | // Enforce both arguments and return `as` type annotations. 22 | "type-annotations": "all", 23 | 24 | /* Code flow rules */ 25 | // Check if a variable is not assigned in all the possible code paths. 26 | "assign-all-paths": "error", 27 | // Consider loops as unsafe code paths. Assingnments inside them may not happen. 28 | "unsafe-path-loop": "error", 29 | // Loop iterator variables should not be outside the loop. 30 | "unsafe-iterators": "error", 31 | // Warn about unreachable code. 32 | "unreachable-code": "error", 33 | // Warn about inconsistent variable casing. 34 | "case-sensitivity": "error", 35 | // Warn about variables being set but never used. 36 | // Set as "warn" instead of "error" beacuse we may want to allow unused variables 37 | // that will be shown on the crash logs. 38 | "unused-variable": "warn", 39 | // verifies consistency of `sub` / `function` returned values 40 | // (missing return, missing value, returned value while function is `as void`,...) 41 | "consistent-return": "error" 42 | }, 43 | "globals": [], 44 | "ignores": [ 45 | "**/roku_modules/**/*" 46 | ] 47 | } -------------------------------------------------------------------------------- /bsconfig/plugins/testAnnotations.ts: -------------------------------------------------------------------------------- 1 | import type { BeforeFileTranspileEvent, CompilerPlugin, Statement } from 'brighterscript'; 2 | import { isBrsFile, createVisitor, WalkMode } from 'brighterscript'; 3 | 4 | /** 5 | * This plugin will act on found annotations. 6 | * Available annotations are: 7 | * - `@deviceOnly`: Removes the annotated statement from the code. 8 | * Use together with `bsconfig.test.json` as an indentifier for 9 | * non device running environments (e.g: `brs` interpreter or `roca`). 10 | */ 11 | export default function plugin() { 12 | return { 13 | name: 'annotations', 14 | beforeFileTranspile: (event: BeforeFileTranspileEvent) => { 15 | if (isBrsFile(event.file)) { 16 | for (const func of event.file.parser.references.functionExpressions) { 17 | func.body.walk(createVisitor({ 18 | DottedSetStatement: (statement) => handleDeviceOnlyAnnotation(statement, event), 19 | ExpressionStatement: (statement) => handleDeviceOnlyAnnotation(statement, event), 20 | ThrowStatement: (statement) => handleDeviceOnlyAnnotation(statement, event) 21 | }), { 22 | walkMode: WalkMode.visitStatements 23 | }); 24 | } 25 | } 26 | } 27 | } as CompilerPlugin; 28 | } 29 | 30 | function handleDeviceOnlyAnnotation(statement: Statement, event: BeforeFileTranspileEvent) { 31 | if (statement.annotations?.find(a => a.name === 'deviceOnly')) { 32 | event.editor.overrideTranspileResult(statement, ''); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sg-flex", 3 | "version": "0.1.0", 4 | "description": "Flexbox for Roku's SceneGraph", 5 | "engines": { 6 | "node": ">=16.14.2", 7 | "npm": ">=8.5.0" 8 | }, 9 | "scripts": { 10 | "build": "rm -r dist/ out/; bsc", 11 | "build:test-project": "npm run build -- --project bsconfig/bsconfig.dev.json && cp package.json dist/roku-deploy-staging && cd test-project && ropm install", 12 | "build:test": "npm run build -- --project bsconfig/bsconfig.test.json", 13 | "test": "npm run build:test && cd dist/roku-deploy-staging && roca", 14 | "lint": "bslint --project 'bsconfig/bsconfig.lint.json' --lintConfig bsconfig/bslint.jsonc", 15 | "lint:fix": "npm run lint -- --fix", 16 | "format:base": "bsfmt 'src/**/*.bs' '!src/**/roku_modules/**/*' --bsfmt-path 'bsconfig/bsfmt.jsonc'", 17 | "format": "npm run format:base -- --check", 18 | "format:fix": "npm run format:base -- --write", 19 | "eslint": "eslint 'bsconfig/**/*.ts'", 20 | "eslint:fix": "npm run eslint -- --fix" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/haystacknews/sg-flex.git" 25 | }, 26 | "keywords": [ 27 | "ropm", 28 | "scenegraph", 29 | "flexbox", 30 | "roku" 31 | ], 32 | "author": "Arturo Cuya", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/haystacknews/sg-flex/issues" 36 | }, 37 | "homepage": "https://github.com/haystacknews/sg-flex#readme", 38 | "devDependencies": { 39 | "@hulu/roca": "^0.25.0", 40 | "@rokucommunity/bslint": "^0.7.0", 41 | "@types/node": "^17.0.23", 42 | "brighterscript": "^0.48.1", 43 | "brighterscript-formatter": "^1.6.10", 44 | "brs": "^0.43.0", 45 | "cz-conventional-changelog": "^3.3.0", 46 | "eslint": "^8.13.0", 47 | "eslint-plugin-github": "^4.3.6", 48 | "eslint-plugin-no-only-tests": "^2.6.0", 49 | "ropm": "^0.10.17", 50 | "ts-node": "^10.7.0", 51 | "typescript": "^4.6.3" 52 | }, 53 | "config": { 54 | "commitizen": { 55 | "path": "./node_modules/cz-conventional-changelog" 56 | } 57 | }, 58 | "ropm": { 59 | "rootDir": "src" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/SGFlex.bs: -------------------------------------------------------------------------------- 1 | import "pkg:/source/SGFlexModel.bs" 2 | 3 | sub init() 4 | m.flexifiedOnce = false 5 | 6 | m.top.observeField("width", "flexify") 7 | m.top.observeField("height", "flexify") 8 | m.top.observeField("direction", "flexify") 9 | m.top.observeField("justifyContent", "flexify") 10 | m.top.observeField("alignItems", "flexify") 11 | end sub 12 | 13 | sub flexify(force = false as boolean) 14 | if (m.top.flexifyOnce and m.flexifiedOnce and force = false) 15 | return 16 | end if 17 | 18 | childrenBoundingRects = [] 19 | for each child in m.top.getChildren(-1, 0) 20 | childrenBoundingRects.push(child.boundingRect()) 21 | end for 22 | 23 | sgfArgs = { 24 | width: m.top.width, 25 | height: m.top.height, 26 | direction: m.top.direction, 27 | justifyContent: m.top.justifyContent, 28 | alignItems: m.top.alignItems 29 | } 30 | sgf = new SGFlexModel(childrenBoundingRects, sgfArgs) 31 | 32 | childrenTranslations = sgf.getTranslations() 33 | 34 | for each childTranslation in childrenTranslations 35 | m.top.getChild(childTranslation.childIndex).translation = [ 36 | childTranslation.x, 37 | childTranslation.y 38 | ] 39 | end for 40 | 41 | m.flexifiedOnce = true 42 | end sub 43 | -------------------------------------------------------------------------------- /src/components/SGFlex.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/manifest: -------------------------------------------------------------------------------- 1 | # Necessary for roca -------------------------------------------------------------------------------- /src/source/SGFlexModel.bs: -------------------------------------------------------------------------------- 1 | class SGFlexModel 2 | private logger as dynamic 3 | 4 | private width as float 5 | private height as float 6 | 7 | ' See enums at the bottom of this file 8 | private direction as string 9 | private justifyContent as string 10 | private alignItems as string 11 | 12 | private mainAxis as string 13 | private crossAxis as string 14 | private mainDimension as string 15 | private crossDimension as string 16 | 17 | private childrenBoundingRects as object 18 | private childrenAccWidth as float 19 | private childrenAccHeight as float 20 | private childrenMaxHeight as float 21 | private childrenMaxWidth as float 22 | 23 | private accMainDimension as float 24 | private accCrossDimension as float 25 | 26 | ' @param {object[]} childrenBoundingRects - An array of bounding rects for each child. 27 | ' @param {object} [_args] - An object containing the following properties: 28 | ' @param {float} [_args.width] - The width of the parent container. If not present it will be set as the accumulated width of the children. 29 | ' @param {float} [_args.height] - The height of the parent container. If not present it will be set as the accumulated height of the children. 30 | ' @param {string} [_args.direction="row"] - The direction of the layout. 31 | ' @param {string} [_args.justifyContent="flexStart"] - The justification of the layout in the main axis. 32 | ' @param {string} [_args.alignItems="flexStart"] - The alignment of the layout in the cross axis. 33 | ' @param {object} [logger=invalid] - A `roku-log` instance. 34 | sub new(childrenBoundingRects as object, _args = {} as object, logger = invalid as dynamic) 35 | m.logger = logger 36 | 37 | m.childrenBoundingRects = childrenBoundingRects 38 | 39 | ' Default values for the arguments 40 | args = { 41 | width: -1, 42 | height: -1, 43 | direction: Direction.row, 44 | justifyContent: JustifyContent.flexStart, 45 | alignItems: AlignItems.flexStart 46 | } 47 | 48 | ' Replace default values with arguments passed from the constructor arguments 49 | for each key in _args.keys() 50 | args[key] = _args[key] 51 | end for 52 | 53 | m.validateArgs(args) 54 | 55 | m.direction = args.direction 56 | m.justifyContent = args.justifyContent 57 | m.alignItems = args.alignItems 58 | 59 | if (m.direction = Direction.row) 60 | m.mainAxis = Axis.x 61 | m.crossAxis = Axis.y 62 | m.mainDimension = Dimension.width 63 | m.crossDimension = Dimension.height 64 | else 65 | m.mainAxis = Axis.y 66 | m.crossAxis = Axis.x 67 | m.mainDimension = Dimension.height 68 | m.crossDimension = Dimension.width 69 | end if 70 | 71 | ' Set accumulated and maximum values 72 | m.childrenAccWidth = 0 73 | m.childrenAccHeight = 0 74 | m.childrenMaxWidth = 0 75 | m.childrenMaxHeight = 0 76 | for each childRect in childrenBoundingRects 77 | m.childrenAccWidth += childRect.width 78 | m.childrenAccHeight += childRect.height 79 | 80 | if (m.childrenMaxWidth < childRect.width) 81 | m.childrenMaxWidth = childRect.width 82 | end if 83 | 84 | if (m.childrenMaxHeight < childRect.height) 85 | m.childrenMaxHeight = childRect.height 86 | end if 87 | end for 88 | 89 | if (m.direction = Direction.row) 90 | m.accMainDimension = m.childrenAccWidth 91 | m.accCrossDimension = m.childrenAccHeight 92 | else 93 | m.accMainDimension = m.childrenAccHeight 94 | m.accCrossDimension = m.childrenAccWidth 95 | end if 96 | 97 | ' If the dimensions are not set (default value is -1), 98 | ' set them to the accumulated values 99 | if (args.width < 0) 100 | m.width = m.childrenAccWidth 101 | else 102 | m.width = args.width 103 | end if 104 | 105 | if (args.height < 0) 106 | m.height = m.childrenAccHeight 107 | else 108 | m.height = args.height 109 | end if 110 | end sub 111 | 112 | ' Returns a list of translations to apply to each original child at the given index 113 | ' to position them according to the `direction`, `justifyContent` and `alignItems` values. 114 | ' @returns { childIndex: integer, x: float, y: float }[] 115 | public function getTranslations() as object 116 | translations = [] 117 | 118 | if (m.childrenBoundingRects.count() = 0) 119 | return translations 120 | end if 121 | 122 | ' Main axis translations 123 | if (m.justifyContent = JustifyContent.flexStart) 124 | if (m.childrenBoundingRects[0][m.mainAxis] <> 0) 125 | firstTranslation = { childIndex: 0 } 126 | firstTranslation[m.mainAxis] = 0 127 | translations.push(firstTranslation) 128 | end if 129 | 130 | for i = 1 to m.childrenBoundingRects.count() - 1 131 | translation = { childIndex: i } 132 | prevTranslation = m.getPreviousTranslation(i, translations) 133 | translation[m.mainAxis] = prevTranslation[m.mainAxis] + m.childrenBoundingRects[i - 1][m.mainDimension] 134 | translation[m.crossAxis] = m.childrenBoundingRects[i][m.crossAxis] 135 | translations.push(translation) 136 | end for 137 | else if (m.justifyContent = JustifyContent.flexEnd) 138 | for i = 0 to m.childrenBoundingRects.count() - 1 139 | translation = { childIndex: i } 140 | 141 | if (i = 0) 142 | translation[m.mainAxis] = m[m.mainDimension] - m.accMainDimension 143 | else 144 | prevTranslation = m.getPreviousTranslation(i, translations) 145 | translation[m.mainAxis] = prevTranslation[m.mainAxis] + m.childrenBoundingRects[i - 1][m.mainDimension] 146 | end if 147 | 148 | translation[m.crossAxis] = m.childrenBoundingRects[i][m.crossAxis] 149 | 150 | translations.push(translation) 151 | end for 152 | else if (m.justifyContent = JustifyContent.center) 153 | spaceBefore = (m[m.mainDimension] - m.accMainDimension) / 2 154 | for i = 0 to m.childrenBoundingRects.count() - 1 155 | translation = { childIndex: i } 156 | if (i = 0) 157 | translation[m.mainAxis] = spaceBefore 158 | else 159 | prevTranslation = m.getPreviousTranslation(i, translations) 160 | translation[m.mainAxis] = prevTranslation[m.mainAxis] + m.childrenBoundingRects[i - 1][m.mainDimension] 161 | end if 162 | translation[m.crossAxis] = m.childrenBoundingRects[i][m.crossAxis] 163 | translations.push(translation) 164 | end for 165 | else if (m.justifyContent = JustifyContent.spaceBetween) 166 | if (m.childrenBoundingRects.count() > 1) 167 | ' SB = (P_dim - max(C_dim)) / (n - 1) 168 | spaceBetween = m[m.mainDimension] 169 | spaceBetween -= m.accMainDimension 170 | spaceBetween /= m.childrenBoundingRects.count() - 1 171 | 172 | for i = 0 to m.childrenBoundingRects.count() - 1 173 | translation = { childIndex: i } 174 | if (i = 0) 175 | translation[m.mainAxis] = 0 176 | else 177 | prevTranslation = m.getPreviousTranslation(i, translations) 178 | prevChild = m.childrenBoundingRects[i - 1] 179 | 180 | ' Translation formula (change "x" to "y" and "w" to "h" for direction = column) 181 | ' T(n)_x = T(n-1)_x + C(n-1)_w + SB 182 | translatedMainAxis = prevTranslation[m.mainAxis] 183 | translatedMainAxis += prevChild[m.mainDimension] 184 | translatedMainAxis += spaceBetween 185 | 186 | translation[m.mainAxis] = translatedMainAxis 187 | end if 188 | 189 | translation[m.crossAxis] = m.childrenBoundingRects[i][m.crossAxis] 190 | translations.push(translation) 191 | end for 192 | end if 193 | else if (m.justifyContent = JustifyContent.spaceAround) 194 | spaceAround = m[m.mainDimension] 195 | spaceAround -= m.accMainDimension 196 | spaceAround /= m.childrenBoundingRects.count() 197 | 198 | for i = 0 to m.childrenBoundingRects.count() - 1 199 | translation = { childIndex: i } 200 | 201 | if (i = 0) 202 | translation[m.mainAxis] = spaceAround / 2 203 | else 204 | prevTranslation = m.getPreviousTranslation(i, translations) 205 | prevChild = m.childrenBoundingRects[i - 1] 206 | 207 | translatedMainAxis = prevTranslation[m.mainAxis] 208 | translatedMainAxis += prevChild[m.mainDimension] 209 | translatedMainAxis += spaceAround 210 | 211 | translation[m.mainAxis] = translatedMainAxis 212 | translation[m.crossAxis] = m.childrenBoundingRects[i][m.crossAxis] 213 | end if 214 | 215 | translations.push(translation) 216 | end for 217 | else if (m.justifyContent = JustifyContent.spaceEvenly) 218 | spaceEvenly = m[m.mainDimension] 219 | spaceEvenly -= m.accMainDimension 220 | spaceEvenly /= m.childrenBoundingRects.count() + 1 221 | 222 | for i = 0 to m.childrenBoundingRects.count() - 1 223 | translation = { childIndex: i } 224 | 225 | if (i = 0) 226 | translation[m.mainAxis] = spaceEvenly 227 | else 228 | prevTranslation = m.getPreviousTranslation(i, translations) 229 | prevChild = m.childrenBoundingRects[i - 1] 230 | 231 | translatedMainAxis = prevTranslation[m.mainAxis] 232 | translatedMainAxis += prevChild[m.mainDimension] 233 | translatedMainAxis += spaceEvenly 234 | 235 | translation[m.mainAxis] = translatedMainAxis 236 | translation[m.crossAxis] = m.childrenBoundingRects[i][m.crossAxis] 237 | end if 238 | 239 | translations.push(translation) 240 | end for 241 | end if 242 | 243 | ' Cross axis translations 244 | for i = 0 to m.childrenBoundingRects.count() - 1 245 | ' Check if there's an existing translation for the current child to reuse it. 246 | existingTranslationIndex = -1 247 | for j = 0 to translations.count() - 1 248 | if (translations[j].childIndex = i) 249 | existingTranslationIndex = j 250 | exit for 251 | end if 252 | end for 253 | 254 | if (m.alignItems = AlignItems.flexStart) 255 | if (existingTranslationIndex <> -1) 256 | translations[existingTranslationIndex][m.crossAxis] = 0 257 | else 258 | translation = { childIndex: i } 259 | translation[m.mainAxis] = m.childrenBoundingRects[i][m.mainAxis] 260 | translation[m.crossAxis] = 0 261 | translations.push(translation) 262 | end if 263 | else if (m.alignItems = AlignItems.flexEnd) 264 | ' The main dimension of the container (`width` or `height`) 265 | ' minus the main dimension of the child 266 | translatedCrossAxis = m[m.crossDimension] - m.childrenBoundingRects[i][m.crossDimension] 267 | 268 | if (existingTranslationIndex <> -1) 269 | translations[existingTranslationIndex][m.crossAxis] = translatedCrossAxis 270 | else 271 | translation = { childIndex: i } 272 | translation[m.mainAxis] = m.childrenBoundingRects[i][m.mainAxis] 273 | translation[m.crossAxis] = translatedCrossAxis 274 | translations.push(translation) 275 | end if 276 | else if (m.alignItems = AlignItems.center) 277 | ' Half of the main dimension of the container (`width` or `height`) 278 | ' minus the main dimension of the child 279 | translatedCrossAxis = (m[m.crossDimension] - m.childrenBoundingRects[i][m.crossDimension]) / 2 280 | 281 | if (existingTranslationIndex <> -1) 282 | translations[existingTranslationIndex][m.crossAxis] = translatedCrossAxis 283 | else 284 | translation = { childIndex: i } 285 | translation[m.mainAxis] = m.childrenBoundingRects[i][m.mainAxis] 286 | translation[m.crossAxis] = translatedCrossAxis 287 | translations.push(translation) 288 | end if 289 | end if 290 | end for 291 | 292 | return translations 293 | end function 294 | 295 | ' Validates the values for direction, justifyContent and alignItems. 296 | ' @throws Will throw an error if an argument is invalid 297 | private sub validateArgs(args as object) 298 | if ((args.direction <> Direction.row) and (args.direction <> Direction.column)) 299 | if (m.logger <> invalid) 300 | m.logger.error(ErrorCode.invalidDirection, args.direction) 301 | end if 302 | @deviceOnly 303 | throw ErrorCode.invalidDirection 304 | end if 305 | 306 | if ((args.justifyContent <> JustifyContent.flexStart) and (args.justifyContent <> JustifyContent.flexEnd) and (args.justifyContent <> JustifyContent.center) and (args.justifyContent <> JustifyContent.spaceBetween) and (args.justifyContent <> JustifyContent.spaceAround) and (args.justifyContent <> JustifyContent.spaceEvenly)) 307 | if (m.logger <> invalid) 308 | m.logger.error(ErrorCode.invalidJustifyContent, args.justifyContent) 309 | end if 310 | @deviceOnly 311 | throw ErrorCode.invalidJustifyContent 312 | end if 313 | 314 | if ((args.alignItems <> AlignItems.flexStart) and (args.alignItems <> AlignItems.flexEnd) and (args.alignItems <> AlignItems.center)) 315 | if (m.logger <> invalid) 316 | m.logger.error(ErrorCode.invalidAlignItems, args.alignItems) 317 | end if 318 | @deviceOnly 319 | throw ErrorCode.invalidAlignItems 320 | end if 321 | end sub 322 | 323 | ' Returns the previous translation. If invalid, returns the previous child coordinates. 324 | private function getPreviousTranslation(currentIndex as integer, translations as object) as object 325 | prevTranslation = translations[translations.count() - 1] 326 | prevChild = m.childrenBoundingRects[currentIndex - 1] 327 | 328 | if (prevTranslation = invalid) 329 | if (prevChild <> invalid) 330 | prevTranslation = { x: prevChild.x, y: prevChild.y } 331 | else 332 | prevTranslation = { x: 0, y: 0 } 333 | end if 334 | end if 335 | 336 | return prevTranslation 337 | end function 338 | end class 339 | 340 | enum Direction 341 | row = "row" 342 | column = "column" 343 | end enum 344 | 345 | enum JustifyContent 346 | flexStart = "flexStart" 347 | flexEnd = "flexEnd" 348 | center = "center" 349 | spaceBetween = "spaceBetween" 350 | spaceAround = "spaceAround" 351 | spaceEvenly = "spaceEvenly" 352 | end enum 353 | 354 | enum AlignItems 355 | flexStart = "flexStart" 356 | flexEnd = "flexEnd" 357 | center = "center" 358 | ' The values `stretch` and `baseline` are not really possible since 359 | ' we can't modify the size of the children or inspect their contents. 360 | end enum 361 | 362 | enum Axis 363 | x = "x" 364 | y = "y" 365 | end enum 366 | 367 | enum Dimension 368 | width = "width" 369 | height = "height" 370 | end enum 371 | 372 | enum ErrorCode 373 | invalidDirection = "Invalid `direction` value." 374 | invalidJustifyContent = "Invalid `justifyContent` value." 375 | invalidAlignItems = "Invalid `alignItems` value." 376 | end enum 377 | -------------------------------------------------------------------------------- /src/tests/constructor.test.bs: -------------------------------------------------------------------------------- 1 | function main(args as object) as object 2 | return roca(args).describe("constructor", sub() 3 | m.it("default values", sub() 4 | childrenBoundingRects = [ 5 | { x: 0, y: 0, width: 3, height: 2 }, 6 | { x: 0, y: 0, width: 5, height: 7 } 7 | { x: 0, y: 0, width: 8, height: 3 } 8 | ] 9 | sgf = new SGFlexModel(childrenBoundingRects) 10 | 11 | m.assert.equal(sgf.width, 16, "incorrect default width") 12 | m.assert.equal(sgf.height, 12, "incorrect default height") 13 | m.assert.equal(sgf.direction, "row", "incorrect default direction") 14 | m.assert.equal(sgf.justifyContent, "flexStart", "incorrect default justifyContent") 15 | m.assert.equal(sgf.alignItems, "flexStart", "incorrect default alignItems") 16 | end sub) 17 | 18 | m.it("numeric values", sub() 19 | childrenBoundingRects = [ 20 | { x: 0, y: 0, width: 3, height: 2 }, 21 | { x: 0, y: 0, width: 5, height: 7 } 22 | { x: 0, y: 0, width: 8, height: 3 } 23 | ] 24 | sgf = new SGFlexModel(childrenBoundingRects) 25 | m.assert.equal(sgf.childrenAccWidth, 16, "incorrect childrenAccWidth") 26 | m.assert.equal(sgf.childrenAccHeight, 12, "incorrect childrenAccHeight") 27 | end sub) 28 | 29 | m.it("direction: row", sub() 30 | sgf = new SGFlexModel([], { direction: "row" }) 31 | m.assert.equal(sgf.mainAxis, "x", "incorrect mainAxis") 32 | m.assert.equal(sgf.crossAxis, "y", "incorrect crossAxis") 33 | m.assert.equal(sgf.mainDimension, "width", "incorrect mainDimension") 34 | m.assert.equal(sgf.crossDimension, "height", "incorrect crossDimension") 35 | end sub) 36 | 37 | m.it("direction: column", sub() 38 | sgf = new SGFlexModel([], { direction: "column" }) 39 | m.assert.equal(sgf.mainAxis, "y", "incorrect mainAxis") 40 | m.assert.equal(sgf.crossAxis, "x", "incorrect crossAxis") 41 | m.assert.equal(sgf.mainDimension, "height", "incorrect mainDimension") 42 | m.assert.equal(sgf.crossDimension, "width", "incorrect crossDimension") 43 | end sub) 44 | end sub) 45 | end function 46 | -------------------------------------------------------------------------------- /src/tests/csswg/8--alignment/8-2--justify-content.test.bs: -------------------------------------------------------------------------------- 1 | function main(args as object) as object 2 | return roca(args).describe("csswg: 8.2 Axis Alignment: the justify-content property", sub() 3 | m.it("flexbox | justify-content: space-between", sub() 4 | args = { 5 | width: 480, 6 | height: 128, 7 | justifyContent: "spaceBetween" 8 | } 9 | boundingRect = { x: 0, y: 0, width: 128, height: 128 } 10 | sgf = new SGFlexModel([boundingRect, boundingRect, boundingRect], args) 11 | expected = [ 12 | { childIndex: 0, x: 0, y: 0 }, 13 | { childIndex: 1, x: 176, y: 0 }, 14 | { childIndex: 2, x: 352, y: 0 } 15 | ] 16 | actual = sgf.getTranslations() 17 | m.assert.equal(FormatJson(actual), FormatJson(expected), "fail") 18 | end sub) 19 | 20 | m.it("flexbox | justify-content: space-between | single item", sub() 21 | args = { 22 | width: 480, 23 | height: 128, 24 | justifyContent: "spaceBetween" 25 | } 26 | boundingRect = { x: 0, y: 0, width: 128, height: 128 } 27 | sgf = new SGFlexModel([boundingRect], args) 28 | expected = [{ childIndex: 0, x: 0, y: 0 }] 29 | actual = sgf.getTranslations() 30 | m.assert.equal(FormatJson(actual), FormatJson(expected), "fail") 31 | end sub) 32 | end sub) 33 | end function 34 | -------------------------------------------------------------------------------- /src/tests/csswg/8--alignment/8-3--align-items.test.bs: -------------------------------------------------------------------------------- 1 | function main(args as object) as object 2 | return roca(args).describe("csswg: 8.3 Cross-axis Alignment: the align-items and align-self properties", sub() 3 | m.it("flexbox | align-items: center", sub() 4 | args = { 5 | width: 482, 6 | height: 98, 7 | alignItems: "center" 8 | } 9 | boundingRect = { x: 0, y: 0, width: 160, height: 32 } 10 | sgf = new SGFlexModel([boundingRect, boundingRect, boundingRect], args) 11 | expected = [ 12 | { childIndex: 1, x: 160, y: 33 }, 13 | { childIndex: 2, x: 320, y: 33 }, 14 | { childIndex: 0, x: 0, y: 33 } 15 | ] 16 | actual = sgf.getTranslations() 17 | m.assert.equal(FormatJson(actual), FormatJson(expected), "fail") 18 | end sub) 19 | end sub) 20 | end function 21 | -------------------------------------------------------------------------------- /src/tests/others/justify-content.test.bs: -------------------------------------------------------------------------------- 1 | function main(args as object) as object 2 | return roca(args).describe("other justifyContent tests", sub() 3 | m.it("direction=column, justifyContent=spaceBetween", sub() 4 | args = { 5 | width: 128, 6 | height: 480, 7 | direction: "column", 8 | justifyContent: "spaceBetween" 9 | } 10 | boundingRect = { x: 0, y: 0, width: 128, height: 128 } 11 | sgf = new SGFlexModel([boundingRect, boundingRect, boundingRect], args) 12 | expected = [ 13 | { childIndex: 0, x: 0, y: 0 }, 14 | { childIndex: 1, x: 0, y: 176 }, 15 | { childIndex: 2, x: 0, y: 352 } 16 | ] 17 | actual = sgf.getTranslations() 18 | m.assert.equal(FormatJson(actual), FormatJson(expected), "fail") 19 | end sub) 20 | 21 | m.it("direction=row, justifyContent=flexEnd", sub() 22 | args = { 23 | width: 512, 24 | height: 128, 25 | direction: "row", 26 | justifyContent: "flexEnd" 27 | } 28 | boundingRect = { x: 0, y: 0, width: 128, height: 128 } 29 | sgf = new SGFlexModel([boundingRect, boundingRect, boundingRect], args) 30 | expected = [ 31 | { childIndex: 0, x: 128, y: 0 }, 32 | { childIndex: 1, x: 256, y: 0 }, 33 | { childIndex: 2, x: 384, y: 0 } 34 | ] 35 | actual = sgf.getTranslations() 36 | m.assert.equal(FormatJson(actual), FormatJson(expected), "fail") 37 | end sub) 38 | 39 | m.it("direction=column, justifyContent=flexEnd", sub() 40 | args = { 41 | width: 128, 42 | height: 512, 43 | direction: "column", 44 | justifyContent: "flexEnd" 45 | } 46 | boundingRect = { x: 0, y: 0, width: 128, height: 128 } 47 | sgf = new SGFlexModel([boundingRect, boundingRect, boundingRect], args) 48 | expected = [ 49 | { childIndex: 0, x: 0, y: 128 }, 50 | { childIndex: 1, x: 0, y: 256 }, 51 | { childIndex: 2, x: 0, y: 384 } 52 | ] 53 | actual = sgf.getTranslations() 54 | m.assert.equal(FormatJson(actual), FormatJson(expected), "fail") 55 | end sub) 56 | 57 | m.it("direction=row, justifyContent=center", sub() 58 | args = { 59 | width: 512, 60 | height: 128, 61 | direction: "row", 62 | justifyContent: "center" 63 | } 64 | boundingRect = { x: 0, y: 0, width: 128, height: 128 } 65 | sgf = new SGFlexModel([boundingRect, boundingRect, boundingRect], args) 66 | expected = [ 67 | { childIndex: 0, x: 64, y: 0 }, 68 | { childIndex: 1, x: 192, y: 0 }, 69 | { childIndex: 2, x: 320, y: 0 } 70 | ] 71 | actual = sgf.getTranslations() 72 | m.assert.equal(FormatJson(actual), FormatJson(expected), "fail") 73 | end sub) 74 | 75 | m.it("direction=row, justifyContent=center", sub() 76 | args = { 77 | width: 128, 78 | height: 512, 79 | direction: "column", 80 | justifyContent: "center" 81 | } 82 | boundingRect = { x: 0, y: 0, width: 128, height: 128 } 83 | sgf = new SGFlexModel([boundingRect, boundingRect, boundingRect], args) 84 | expected = [ 85 | { childIndex: 0, x: 0, y: 64 }, 86 | { childIndex: 1, x: 0, y: 192 }, 87 | { childIndex: 2, x: 0, y: 320 } 88 | ] 89 | actual = sgf.getTranslations() 90 | m.assert.equal(FormatJson(actual), FormatJson(expected), "fail") 91 | end sub) 92 | 93 | m.it("spaceEvenly with 1 element", sub() 94 | args = { 95 | width: 1703, 96 | height: 136, 97 | direction: "row", 98 | justifyContent: "spaceEvenly", 99 | alignItems: "flexStart" 100 | } 101 | boundingRect = { x: 0, y: 0, width: 100, height: 100 } 102 | sgf = new SGFlexModel([boundingRect], args) 103 | expected = [ 104 | { childIndex: 0, x: 801.5, y: 0 }, 105 | ] 106 | actual = sgf.getTranslations() 107 | m.assert.equal(FormatJson(actual), FormatJson(expected), "fail") 108 | end sub) 109 | end sub) 110 | end function 111 | -------------------------------------------------------------------------------- /test-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "sgf": "file:../dist/roku-deploy-staging" 5 | }, 6 | "devDependencies": { 7 | "ropm": "^0.10.5" 8 | }, 9 | "ropm": { 10 | "rootDir": "./src" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test-project/src/components/TestScene.brs: -------------------------------------------------------------------------------- 1 | sub init() 2 | m.sgf = m.top.findNode("sgFlex") 3 | m.sgf.width = 1790 4 | m.sgf.height = 596 5 | m.childrenButtons = m.top.findNode("childrenButtons") 6 | m.directionOptions = m.top.findNode("directionOptions") 7 | m.justifyContentOptions = m.top.findNode("justifyContentOptions") 8 | m.alignItemsOptions = m.top.findNode("alignItemsOptions") 9 | 10 | m.childrenButtons.observeField("buttonSelected", "handleChildButtonSelected") 11 | m.directionOptions.observeField("itemSelected", "handleDirectionSelected") 12 | m.justifyContentOptions.observeField("itemSelected", "handleJustifyContentSelected") 13 | m.alignItemsOptions.observeField("itemSelected", "handleAlignItemsSelected") 14 | 15 | ' This array represents which column is focused. The index 16 | ' will be changed by onKeyEvent() when the user presses left or right. 17 | m.optionColumns = [ 18 | m.childrenButtons, 19 | m.directionOptions, 20 | m.justifyContentOptions, 21 | m.alignItemsOptions 22 | ] 23 | m.top.observeField("currentOptionColumnIndex", "handleCurrentOptionColumnIndexChange") 24 | m.top.currentOptionColumnIndex = 0 25 | 26 | ' Initial values 27 | m.initialChildrenButtons = [ 28 | "Add child", 29 | "Remove child", 30 | "Child Width: invalid", 31 | "Child Height: invalid" 32 | ] 33 | 34 | m.directionValues = ["row", "column"] 35 | 36 | m.justifyContentValues = [ 37 | "flexStart", 38 | "flexEnd", 39 | "center", 40 | "spaceBetween", 41 | "spaceAround", 42 | "spaceEvenly" 43 | ] 44 | 45 | m.alignItemsValues = [ 46 | "flexStart", 47 | "flexEnd", 48 | "center" 49 | ] 50 | 51 | ' Colors for the children 52 | m.colors = [ 53 | ' red 54 | "0xA90F0F", 55 | ' green, 56 | "0x2B9857", 57 | ' blue 58 | "0x2B4A98", 59 | ' yellow 60 | "0x98942B", 61 | ' magenta 62 | "0x982B5F", 63 | ' cyan 64 | "0x2B9198" 65 | ] 66 | 67 | ' Initialize children buttons 68 | m.childrenButtons.buttons = m.initialChildrenButtons 69 | 70 | ' Initialize values 71 | m.top.observeField("childWidth", "handleChildWidthChange") 72 | m.top.observeField("childHeight", "handleChildHeightChange") 73 | m.top.childWidth = 170 74 | m.top.childHeight = 170 75 | 76 | ' Initialize direction options 77 | setupRadioButtonListValues("direction", m.directionOptions, m.directionValues) 78 | ' Start with "row" selected 79 | m.directionOptions.checkedItem = 0 80 | m.directionOptions.jumpToitem = 0 81 | 82 | ' Initialize justifyContent options 83 | setupRadioButtonListValues("justifyContent", m.justifyContentOptions, m.justifyContentValues) 84 | ' Start with "spaceBetween" selected 85 | m.justifyContentOptions.checkedItem = 3 86 | m.justifyContentOptions.jumpToitem = 3 87 | 88 | ' Initialize alignItems options 89 | setupRadioButtonListValues("alignItems", m.alignItemsOptions, m.alignItemsValues) 90 | ' Start with "center" selected 91 | m.alignItemsOptions.checkedItem = 2 92 | m.alignItemsOptions.jumpToitem = 2 93 | end sub 94 | 95 | sub setupRadioButtonListValues(title as string, node as object, values as object) 96 | contentNode = CreateObject("roSGNode", "ContentNode") 97 | contentNode.contentType = "section" 98 | contentNode.title = title 99 | for each option in values 100 | optionNode = contentNode.CreateChild("ContentNode") 101 | optionNode.title = option 102 | end for 103 | node.content = contentNode 104 | end sub 105 | 106 | sub handleDirectionSelected() 107 | ? "new direction selected", m.directionValues[m.directionOptions.itemSelected] 108 | m.sgf.direction = m.directionValues[m.directionOptions.itemSelected] 109 | end sub 110 | 111 | sub handleJustifyContentSelected() 112 | ? "new justifyContent selected", m.justifyContentValues[m.justifyContentOptions.itemSelected] 113 | m.sgf.justifyContent = m.justifyContentValues[m.justifyContentOptions.itemSelected] 114 | end sub 115 | 116 | sub handleAlignItemsSelected() 117 | ? "new alignItems selected", m.alignItemsValues[m.alignItemsOptions.itemSelected] 118 | m.sgf.alignItems = m.alignItemsValues[m.alignItemsOptions.itemSelected] 119 | end sub 120 | 121 | sub handleChildButtonSelected() 122 | if (m.childrenButtons.buttonSelected = 0) 123 | handleAddChild() 124 | else if (m.childrenButtons.buttonSelected = 1) 125 | handleRemoveChild() 126 | else if (m.childrenButtons.buttonSelected = 2) 127 | else if (m.childrenButtons.buttonSelected = 3) 128 | end if 129 | end sub 130 | 131 | sub handleAddChild() 132 | newChild = CreateObject("roSGNode", "Rectangle") 133 | newChild.width = m.top.childWidth 134 | newChild.height = m.top.childHeight 135 | newChild.color = m.colors[m.sgf.getChildCount() - Int((m.sgf.getChildCount()) / m.colors.count()) * m.colors.count()] 136 | 137 | newChildLabel = CreateObject("roSGNode", "Label") 138 | newChildLabel.text = m.sgf.getChildCount() 139 | newChild.appendChild(newChildLabel) 140 | 141 | m.sgf.appendChild(newChild) 142 | m.sgf.callFunc("flexify") 143 | end sub 144 | 145 | sub handleRemoveChild() 146 | m.sgf.removeChildIndex(m.sgf.getChildCount() - 1) 147 | m.sgf.callFunc("flexify") 148 | end sub 149 | 150 | sub handleChildWidthChange() 151 | ? "new childWidth", m.top.childWidth.toStr() 152 | newButtons = m.childrenButtons.buttons 153 | newButtons[2] = "Child Width: " + m.top.childWidth.ToStr() + "px" 154 | m.childrenButtons.buttons = newButtons 155 | end sub 156 | 157 | sub handleChildHeightChange() 158 | ? "new childHeight", m.top.childHeight.toStr() 159 | newButtons = m.childrenButtons.buttons 160 | newButtons[3] = "Child Height: " + m.top.childHeight.ToStr() + "px" 161 | m.childrenButtons.buttons = newButtons 162 | end sub 163 | 164 | sub handleCurrentOptionColumnIndexChange() 165 | m.optionColumns[m.top.currentOptionColumnIndex].setFocus(true) 166 | end sub 167 | 168 | function onKeyEvent(key as string, press as boolean) as boolean 169 | if (press) 170 | if (key = "left") 171 | newIndex = m.top.currentOptionColumnIndex - 1 172 | if (newIndex < 0) 173 | newIndex = m.optionColumns.count() - 1 174 | end if 175 | m.top.currentOptionColumnIndex = newIndex 176 | return true 177 | else if (key = "right") 178 | newIndex = m.top.currentOptionColumnIndex + 1 179 | if (newIndex >= m.optionColumns.count()) 180 | newIndex = 0 181 | end if 182 | m.top.currentOptionColumnIndex = newIndex 183 | return true 184 | end if 185 | end if 186 | 187 | return false 188 | end function 189 | -------------------------------------------------------------------------------- /test-project/src/components/TestScene.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 |