├── .yarnrc.yml ├── .gitignore ├── watch-test ├── page │ ├── i18n │ │ └── en-US.po │ ├── picker.js │ ├── page3.js │ ├── page1.js │ ├── page2.js │ ├── scrollable.js │ ├── pager.js │ └── index.js ├── app-side │ ├── i18n │ │ └── en-US.po │ └── index.js ├── global.d.ts ├── assets │ └── 480x480-gtr-3-pro │ │ ├── icon.png │ │ └── zapp.png ├── app.js ├── .gitignore ├── jsconfig.json ├── package.json └── app.json ├── assets ├── example.png ├── zapp-dark.png ├── zapp-light.png ├── screenshot1.png ├── screenshot2.png └── zapp.svg ├── @zapp-framework ├── watch │ ├── src │ │ ├── types.ts │ │ ├── global.d.ts │ │ ├── screens │ │ │ ├── SimpleScreen.ts │ │ │ ├── ScrollableScreen.ts │ │ │ ├── PageWrapper.ts │ │ │ └── ScreenPager.ts │ │ ├── Application.ts │ │ ├── index.ts │ │ ├── ZappWatch.ts │ │ ├── KeyValueStorage.ts │ │ └── Navigator.ts │ ├── tsconfig.json │ ├── README.md │ └── package.json ├── ui │ ├── src │ │ ├── global.d.ts │ │ ├── index.ts │ │ ├── Divider.ts │ │ ├── Text.ts │ │ ├── ActivityIndicator.ts │ │ ├── Switch.ts │ │ ├── Theme.ts │ │ ├── CheckBox.ts │ │ ├── PageIndicator.ts │ │ ├── RadioGroup.ts │ │ └── Button.ts │ ├── tsconfig.json │ ├── README.md │ └── package.json ├── core │ ├── tsconfig.json │ ├── jest.config.json │ ├── src │ │ ├── working_tree │ │ │ ├── effects │ │ │ │ ├── remember.ts │ │ │ │ ├── registerCrownEventHandler.ts │ │ │ │ ├── registerGestureEventHandler.ts │ │ │ │ ├── ButtonActions.ts │ │ │ │ ├── RememberedValue.ts │ │ │ │ ├── registerHomeButtonEventHandler.ts │ │ │ │ ├── registerShortcutButtonEventHandler.ts │ │ │ │ ├── rememberLauncherForResult.ts │ │ │ │ ├── rememberImmutable.ts │ │ │ │ ├── sideEffect.ts │ │ │ │ ├── animation │ │ │ │ │ ├── TimingAnimation.ts │ │ │ │ │ ├── Animation.ts │ │ │ │ │ ├── Easing.ts │ │ │ │ │ └── RepeatAnimation.ts │ │ │ │ ├── rememberObservable.ts │ │ │ │ └── RememberedMutableValue.ts │ │ │ ├── props │ │ │ │ ├── StackConfig.ts │ │ │ │ ├── Config.ts │ │ │ │ ├── RowConfig.ts │ │ │ │ ├── ColumnConfig.ts │ │ │ │ ├── TextConfig.ts │ │ │ │ ├── ImageConfig.ts │ │ │ │ ├── ArcConfig.ts │ │ │ │ ├── LayoutConfig.ts │ │ │ │ ├── BaseConfig.ts │ │ │ │ └── types.ts │ │ │ ├── EventNode.ts │ │ │ ├── views │ │ │ │ ├── Arc.ts │ │ │ │ ├── BareText.ts │ │ │ │ ├── Image.ts │ │ │ │ ├── Row.ts │ │ │ │ ├── Column.ts │ │ │ │ ├── Stack.ts │ │ │ │ ├── Screen.ts │ │ │ │ └── Custom.ts │ │ │ ├── EffectNode.ts │ │ │ ├── RememberNode.ts │ │ │ ├── ViewNode.ts │ │ │ ├── WorkingNode.ts │ │ │ ├── SavedTreeState.ts │ │ │ └── WorkingTree.ts │ │ ├── NodeType.ts │ │ ├── Application.ts │ │ ├── renderer │ │ │ ├── DummyViewManager.ts │ │ │ ├── RenderedTree.ts │ │ │ ├── ViewManager.ts │ │ │ └── GlobalEventManager.ts │ │ ├── ZappInterface.ts │ │ ├── __tests__ │ │ │ ├── PrefixTree.test.ts │ │ │ ├── StackLayout.test.ts │ │ │ ├── RowLayout.test.ts │ │ │ └── ColumnLayout.test.ts │ │ ├── utils.ts │ │ ├── Navigator.ts │ │ ├── PrefixTree.ts │ │ ├── Color.ts │ │ └── index.ts │ └── package.json └── web │ ├── tsconfig.json │ ├── src │ ├── Application.ts │ ├── index.ts │ ├── rememberScrollPosition.ts │ ├── HashNavigator.ts │ └── ZappWeb.ts │ ├── README.md │ └── package.json ├── web-test ├── dist │ ├── zapp.png │ └── index.html ├── tsconfig.json ├── webpack.config.cjs ├── src │ ├── Page.ts │ ├── CustomButton.ts │ └── NavBar.ts └── package.json ├── .prettierrc ├── package.json ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── run-core-tests.yml ├── .eslintrc └── LICENSE.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .yarn 3 | build 4 | **/dist/bundle.js 5 | .DS_Store -------------------------------------------------------------------------------- /watch-test/page/i18n/en-US.po: -------------------------------------------------------------------------------- 1 | msgid "example" 2 | msgstr "This is an example in device" -------------------------------------------------------------------------------- /watch-test/app-side/i18n/en-US.po: -------------------------------------------------------------------------------- 1 | msgid "example" 2 | msgstr "This is an example in app-side" -------------------------------------------------------------------------------- /watch-test/global.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /assets/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-piasecki/zapp-framework/HEAD/assets/example.png -------------------------------------------------------------------------------- /@zapp-framework/watch/src/types.ts: -------------------------------------------------------------------------------- 1 | export enum Direction { 2 | Vertical, 3 | Horizontal, 4 | } 5 | -------------------------------------------------------------------------------- /assets/zapp-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-piasecki/zapp-framework/HEAD/assets/zapp-dark.png -------------------------------------------------------------------------------- /assets/zapp-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-piasecki/zapp-framework/HEAD/assets/zapp-light.png -------------------------------------------------------------------------------- /assets/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-piasecki/zapp-framework/HEAD/assets/screenshot1.png -------------------------------------------------------------------------------- /assets/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-piasecki/zapp-framework/HEAD/assets/screenshot2.png -------------------------------------------------------------------------------- /web-test/dist/zapp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-piasecki/zapp-framework/HEAD/web-test/dist/zapp.png -------------------------------------------------------------------------------- /@zapp-framework/ui/src/global.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | declare global { 4 | const px: (x: number) => number 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /watch-test/assets/480x480-gtr-3-pro/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-piasecki/zapp-framework/HEAD/watch-test/assets/480x480-gtr-3-pro/icon.png -------------------------------------------------------------------------------- /watch-test/assets/480x480-gtr-3-pro/zapp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-piasecki/zapp-framework/HEAD/watch-test/assets/480x480-gtr-3-pro/zapp.png -------------------------------------------------------------------------------- /@zapp-framework/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build/" 5 | }, 6 | "include": ["src"], 7 | "exclude": ["node_modules", "build", "src/__tests__"] 8 | } 9 | -------------------------------------------------------------------------------- /@zapp-framework/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build/" 5 | }, 6 | "include": ["src"], 7 | "exclude": ["node_modules", "build", "src/__tests__"] 8 | } 9 | -------------------------------------------------------------------------------- /@zapp-framework/watch/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build/" 5 | }, 6 | "include": ["src"], 7 | "exclude": ["node_modules", "build", "src/__tests__"] 8 | } 9 | -------------------------------------------------------------------------------- /@zapp-framework/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build/" 5 | }, 6 | "include": ["src"], 7 | "exclude": ["node_modules", "build", "src/__tests__"] 8 | } 9 | -------------------------------------------------------------------------------- /watch-test/app-side/index.js: -------------------------------------------------------------------------------- 1 | import { gettext } from 'i18n' 2 | 3 | AppSideService({ 4 | onInit() { 5 | console.log(gettext('example')) 6 | }, 7 | 8 | onRun() { 9 | }, 10 | 11 | onDestroy() { 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /watch-test/app.js: -------------------------------------------------------------------------------- 1 | import '@zapp-framework/watch' 2 | import { setTheme } from '@zapp-framework/ui' 3 | import { Application } from '@zapp-framework/core' 4 | 5 | Application({ 6 | onInit() { 7 | setTheme() 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /web-test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build/", 5 | "target": "ES6" 6 | }, 7 | "include": ["src"], 8 | "exclude": ["node_modules", "build", "src/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /watch-test/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/** 3 | dist/* 4 | npm-debug.log 5 | yarn-debug.log* 6 | yarn-error.log* 7 | yarn.lock 8 | package-lock.json 9 | selenium-debug.log 10 | .idea 11 | .vscode 12 | *.suo 13 | *.ntvs* 14 | *.njsproj 15 | *.sln -------------------------------------------------------------------------------- /@zapp-framework/core/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "testEnvironment": "node", 4 | "transform": { 5 | "^.+\\.ts?$": "ts-jest" 6 | }, 7 | "resolver": "jest-ts-webcompat-resolver", 8 | "transformIgnorePatterns": ["/node_modules/"] 9 | } 10 | -------------------------------------------------------------------------------- /@zapp-framework/web/src/Application.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@zapp-framework/core' 2 | 3 | export function Application(config: ApplicationConfig) { 4 | config.onInit?.() 5 | 6 | window.addEventListener('beforeunload', () => { 7 | config.onDestroy?.() 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/remember.ts: -------------------------------------------------------------------------------- 1 | import { RememberedMutableValue } from './RememberedMutableValue.js' 2 | import { rememberObservable } from './rememberObservable.js' 3 | 4 | export function remember(value: T): RememberedMutableValue { 5 | return rememberObservable(value) 6 | } 7 | -------------------------------------------------------------------------------- /watch-test/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "checkJs": true 6 | }, 7 | "exclude": [ 8 | "node_modules", 9 | "**/node_modules/*" 10 | ], 11 | "files": [ 12 | "node_modules/@zeppos/device-types/index.d.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": "true", 3 | "scripts": { 4 | "build": "cd @zapp-framework/core && yarn build && cd .. && cd web && yarn build && cd .. && cd watch && yarn build && cd .. && cd ui && yarn build" 5 | }, 6 | "workspaces": [ 7 | "@zapp-framework/*", 8 | "web-test", 9 | "watch-test" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/NodeType.ts: -------------------------------------------------------------------------------- 1 | export enum NodeType { 2 | Root = 'root', 3 | Recomposing = 'recomposing', 4 | 5 | Custom = 'custom', 6 | Screen = 'screen', 7 | Stack = 'stack', 8 | Column = 'column', 9 | Row = 'row', 10 | Text = 'text', 11 | Arc = 'arc', 12 | Image = 'image', 13 | 14 | Remember = 'remember', 15 | Effect = 'effect', 16 | Event = 'event', 17 | } 18 | -------------------------------------------------------------------------------- /@zapp-framework/web/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Zapp logo 5 | 6 |

7 | 8 | Web bindings for Zapp framework. -------------------------------------------------------------------------------- /@zapp-framework/watch/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Zapp logo 5 | 6 |

7 | 8 | ZeppOS bindings for Zapp framework. -------------------------------------------------------------------------------- /@zapp-framework/ui/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Zapp logo 5 | 6 |

7 | 8 | UI components & theming for Zapp framework. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2015", 4 | "noImplicitAny": true, 5 | "removeComments": true, 6 | "preserveConstEnums": true, 7 | "declaration": true, 8 | "sourceMap": false, 9 | "strictNullChecks": true, 10 | "target": "ES6", 11 | "moduleResolution": "node", 12 | "types": ["jest"], 13 | "esModuleInterop": true, 14 | "stripInternal": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /watch-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "empty", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "zeus dev" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@zapp-framework/core": "*", 13 | "@zapp-framework/ui": "*", 14 | "@zapp-framework/watch": "*" 15 | }, 16 | "devDependencies": { 17 | "@zeppos/device-types": "^1.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/StackConfig.ts: -------------------------------------------------------------------------------- 1 | import { LayoutConfigBuilder } from './LayoutConfig.js' 2 | import { StackAlignment } from './types.js' 3 | 4 | export function StackConfig(id: string) { 5 | return new StackConfigBuilder(id) 6 | } 7 | 8 | export class StackConfigBuilder extends LayoutConfigBuilder { 9 | public alignment(alignment: StackAlignment) { 10 | this.config.stackAlignment = alignment 11 | return this 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /web-test/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zapp web tester 6 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/Application.ts: -------------------------------------------------------------------------------- 1 | export interface ApplicationConfig { 2 | onInit?: (params?: string) => void 3 | onDestroy?: () => void 4 | } 5 | 6 | let applicationImplementation: (config: ApplicationConfig) => void 7 | 8 | export function Application(config: ApplicationConfig) { 9 | applicationImplementation(config) 10 | } 11 | 12 | export function setApplicationImplementation(app: (config: ApplicationConfig) => void) { 13 | applicationImplementation = app 14 | } 15 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/EventNode.ts: -------------------------------------------------------------------------------- 1 | import { WorkingNode } from './WorkingNode.js' 2 | 3 | export enum EventType { 4 | HomeButton, 5 | ShortcutButton, 6 | Crown, 7 | Gesture, 8 | } 9 | 10 | export enum ButtonAction { 11 | Press, 12 | Release, 13 | Click, 14 | LongPress, 15 | } 16 | 17 | export class EventNode extends WorkingNode { 18 | public handler: (...args: any[]) => boolean 19 | public eventType: EventType 20 | public buttonAction?: ButtonAction 21 | } 22 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/global.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | declare global { 4 | const hmSetting: any 5 | const hmUI: any 6 | const hmApp: any 7 | const hmFS: any 8 | 9 | const getApp: () => any 10 | const Page: (config: { 11 | onInit?: (params: string) => void 12 | build: () => void 13 | onDestroy?: () => void 14 | }) => void 15 | const App: (config: { 16 | globalData: any 17 | onCreate: (params: string) => void 18 | onDestroy?: () => void 19 | }) => void 20 | } 21 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/Config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigType } from './types' 2 | 3 | export class ConfigBuilder { 4 | protected config: ConfigType 5 | 6 | constructor(id: string) { 7 | this.config = { 8 | id: id, 9 | } 10 | } 11 | 12 | public build() { 13 | return this.config 14 | } 15 | 16 | public merge(other: ConfigBuilder) { 17 | const id = this.config.id 18 | Object.assign(this.config, other.config) 19 | this.config.id = id 20 | return this 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web-test/webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | entry: './src/index.ts', 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.tsx?$/, 9 | use: 'ts-loader', 10 | exclude: /node_modules/, 11 | }, 12 | ], 13 | }, 14 | resolve: { 15 | extensions: ['.tsx', '.ts', '.js'], 16 | }, 17 | output: { 18 | filename: 'bundle.js', 19 | path: path.resolve(__dirname, 'dist'), 20 | }, 21 | mode: 'development', 22 | watch: true, 23 | } 24 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/registerCrownEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { EventType } from '../EventNode.js' 2 | import { ViewNode } from '../ViewNode.js' 3 | import { WorkingTree } from '../WorkingTree.js' 4 | 5 | export function registerCrownEventHandler(handler: (delta: number) => boolean) { 6 | const current = WorkingTree.current as ViewNode 7 | const context = WorkingTree.event(current) 8 | 9 | context.handler = handler 10 | context.eventType = EventType.Crown 11 | 12 | current.children.push(context) 13 | } 14 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/RowConfig.ts: -------------------------------------------------------------------------------- 1 | import { LayoutConfigBuilder } from './LayoutConfig.js' 2 | import { Alignment, Arrangement } from './types.js' 3 | 4 | export function RowConfig(id: string) { 5 | return new RowConfigBuilder(id) 6 | } 7 | 8 | export class RowConfigBuilder extends LayoutConfigBuilder { 9 | public arrangement(arrangement: Arrangement) { 10 | this.config.arrangement = arrangement 11 | return this 12 | } 13 | 14 | public alignment(alignment: Alignment) { 15 | this.config.alignment = alignment 16 | return this 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/views/Arc.ts: -------------------------------------------------------------------------------- 1 | import { ViewNode } from '../ViewNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { NodeType } from '../../NodeType.js' 4 | import { ConfigBuilder } from '../props/Config.js' 5 | 6 | export function Arc(configBuilder: ConfigBuilder) { 7 | const config = configBuilder.build() 8 | const current = WorkingTree.current as ViewNode 9 | 10 | const context = WorkingTree.create(current, { 11 | id: config.id, 12 | type: NodeType.Arc, 13 | config: config, 14 | }) 15 | 16 | current.children.push(context) 17 | } 18 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/ColumnConfig.ts: -------------------------------------------------------------------------------- 1 | import { LayoutConfigBuilder } from './LayoutConfig.js' 2 | import { Alignment, Arrangement } from './types.js' 3 | 4 | export function ColumnConfig(id: string) { 5 | return new ColumnConfigBuilder(id) 6 | } 7 | 8 | export class ColumnConfigBuilder extends LayoutConfigBuilder { 9 | public arrangement(arrangement: Arrangement) { 10 | this.config.arrangement = arrangement 11 | return this 12 | } 13 | 14 | public alignment(alignment: Alignment) { 15 | this.config.alignment = alignment 16 | return this 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | A code snippet or a link to the repository 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Theme, ThemeInterface, setTheme } from './Theme.js' 2 | export { ActivityIndicator, ActivityIndicatorConfig } from './ActivityIndicator.js' 3 | export { Text } from './Text.js' 4 | export { Button, ButtonConfig, ButtonStyle } from './Button.js' 5 | export { PageIndicator, PageIndicatorConfig } from './PageIndicator.js' 6 | export { Switch, SwitchConfig } from './Switch.js' 7 | export { Divider, DividerConfig } from './Divider.js' 8 | export { RadioGroup, RadioButton, RadioGroupConfig } from './RadioGroup.js' 9 | export { CheckBox, CheckBoxConfig } from './CheckBox.js' 10 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/views/BareText.ts: -------------------------------------------------------------------------------- 1 | import { ViewNode } from '../ViewNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { NodeType } from '../../NodeType.js' 4 | import { ConfigBuilder } from '../props/Config.js' 5 | 6 | export function BareText(configBuilder: ConfigBuilder, text: string) { 7 | const config = configBuilder.build() 8 | config.text = text 9 | const current = WorkingTree.current as ViewNode 10 | 11 | const context = WorkingTree.create(current, { 12 | id: config.id, 13 | type: NodeType.Text, 14 | config: config, 15 | }) 16 | 17 | current.children.push(context) 18 | } 19 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/views/Image.ts: -------------------------------------------------------------------------------- 1 | import { ViewNode } from '../ViewNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { NodeType } from '../../NodeType.js' 4 | import { ConfigBuilder } from '../props/Config.js' 5 | 6 | export function Image(configBuilder: ConfigBuilder, source: string) { 7 | const config = configBuilder.build() 8 | config.source = source 9 | const current = WorkingTree.current as ViewNode 10 | 11 | const context = WorkingTree.create(current, { 12 | id: config.id, 13 | type: NodeType.Image, 14 | config: config, 15 | }) 16 | 17 | current.children.push(context) 18 | } 19 | -------------------------------------------------------------------------------- /web-test/src/Page.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Config, 3 | Row, 4 | Stack, 5 | StackConfig, 6 | SimpleScreen, 7 | StackAlignment, 8 | RowConfig, 9 | } from '@zapp-framework/core' 10 | import { NavBar, RouteInfo } from './NavBar' 11 | 12 | export function Page( 13 | routes: RouteInfo[], 14 | content: () => void, 15 | alignment: StackAlignment = StackAlignment.Center 16 | ) { 17 | SimpleScreen(Config('screen'), () => { 18 | Row(RowConfig('navbar-container').fillSize().background(0x000000), () => { 19 | NavBar(routes) 20 | Stack(StackConfig('content').weight(1).fillHeight().alignment(alignment), content) 21 | }) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/screens/SimpleScreen.ts: -------------------------------------------------------------------------------- 1 | import { ScreenBody, ConfigBuilder } from '@zapp-framework/core' 2 | import { PageWrapper } from './PageWrapper.js' 3 | import { viewManagerInstance } from './../WatchViewManager.js' 4 | 5 | export function SimpleScreen( 6 | configBuilder: ConfigBuilder, 7 | body?: (params?: Record) => void 8 | ) { 9 | PageWrapper({ 10 | build: (params) => { 11 | ScreenBody(configBuilder, () => { 12 | body?.(params) 13 | }) 14 | }, 15 | initialize: () => { 16 | hmUI.setLayerScrolling(false) 17 | viewManagerInstance.setNoScrolling() 18 | }, 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/registerGestureEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { EventType } from '../EventNode.js' 2 | import { ViewNode } from '../ViewNode.js' 3 | import { WorkingTree } from '../WorkingTree.js' 4 | 5 | export enum GestureType { 6 | Up = 'up', 7 | Down = 'down', 8 | Left = 'left', 9 | Right = 'right', 10 | } 11 | 12 | export function registerGestureEventHandler(handler: (gesture: GestureType) => boolean) { 13 | const current = WorkingTree.current as ViewNode 14 | const context = WorkingTree.event(current) 15 | 16 | context.handler = handler 17 | context.eventType = EventType.Gesture 18 | 19 | current.children.push(context) 20 | } 21 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/TextConfig.ts: -------------------------------------------------------------------------------- 1 | import { BaseConfigBuilder } from './BaseConfig.js' 2 | import { Alignment } from './types.js' 3 | 4 | export function TextConfig(id: string) { 5 | return new TextConfigBuilder(id) 6 | } 7 | 8 | export class TextConfigBuilder extends BaseConfigBuilder { 9 | public textColor(textColor: number) { 10 | this.config.textColor = textColor 11 | return this 12 | } 13 | 14 | public textSize(textSize: number) { 15 | this.config.textSize = textSize 16 | return this 17 | } 18 | 19 | public alignment(alignment: Alignment) { 20 | this.config.alignment = alignment 21 | return this 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/ImageConfig.ts: -------------------------------------------------------------------------------- 1 | import { BaseConfigBuilder } from './BaseConfig.js' 2 | 3 | export function ImageConfig(id: string) { 4 | return new ImageConfigBuilder(id) 5 | } 6 | 7 | export class ImageConfigBuilder extends BaseConfigBuilder { 8 | public innerOffset(x: number, y: number) { 9 | this.config.innerOffsetX = x 10 | this.config.innerOffsetY = y 11 | return this 12 | } 13 | 14 | public origin(x: number, y: number) { 15 | this.config.originX = x 16 | this.config.originY = y 17 | return this 18 | } 19 | 20 | public rotation(angle: number) { 21 | this.config.rotation = angle 22 | return this 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/views/Row.ts: -------------------------------------------------------------------------------- 1 | import { ViewNode } from '../ViewNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { NodeType } from '../../NodeType.js' 4 | import { ConfigBuilder } from '../props/Config.js' 5 | 6 | export function Row(configBuilder: ConfigBuilder, body?: () => void) { 7 | const config = configBuilder.build() 8 | const current = WorkingTree.current as ViewNode 9 | 10 | const context = WorkingTree.create(current, { 11 | id: config.id, 12 | type: NodeType.Row, 13 | config: config, 14 | body: body, 15 | }) 16 | 17 | current.children.push(context) 18 | 19 | WorkingTree.withContext(context, body) 20 | } 21 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/views/Column.ts: -------------------------------------------------------------------------------- 1 | import { ViewNode } from '../ViewNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { NodeType } from '../../NodeType.js' 4 | import { ConfigBuilder } from '../props/Config.js' 5 | 6 | export function Column(configBuilder: ConfigBuilder, body?: () => void) { 7 | const config = configBuilder.build() 8 | const current = WorkingTree.current as ViewNode 9 | 10 | const context = WorkingTree.create(current, { 11 | id: config.id, 12 | type: NodeType.Column, 13 | config: config, 14 | body: body, 15 | }) 16 | 17 | current.children.push(context) 18 | 19 | WorkingTree.withContext(context, body) 20 | } 21 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/views/Stack.ts: -------------------------------------------------------------------------------- 1 | import { ViewNode } from '../ViewNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { NodeType } from '../../NodeType.js' 4 | import { ConfigBuilder } from '../props/Config.js' 5 | 6 | export function Stack(configBuilder: ConfigBuilder, body?: () => void) { 7 | const config = configBuilder.build() 8 | const current = WorkingTree.current as ViewNode 9 | 10 | const context = WorkingTree.create(current, { 11 | id: config.id, 12 | type: NodeType.Stack, 13 | config: config, 14 | body: body, 15 | }) 16 | 17 | current.children.push(context) 18 | 19 | WorkingTree.withContext(context, body) 20 | } 21 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/ButtonActions.ts: -------------------------------------------------------------------------------- 1 | import { ButtonAction } from '../EventNode.js' 2 | 3 | export interface ButtonActions extends Record boolean) | undefined> { 4 | onPress?: () => boolean 5 | onRelease?: () => boolean 6 | onClick?: () => boolean 7 | onLongPress?: () => boolean 8 | } 9 | 10 | export function getAction(handlerName: keyof ButtonActions): ButtonAction { 11 | switch (handlerName) { 12 | case 'onPress': 13 | return ButtonAction.Press 14 | case 'onRelease': 15 | return ButtonAction.Release 16 | case 'onClick': 17 | return ButtonAction.Click 18 | default: 19 | return ButtonAction.LongPress 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/RememberedValue.ts: -------------------------------------------------------------------------------- 1 | import { RememberNode } from '../RememberNode.js' 2 | 3 | export class RememberedValue { 4 | protected _value: T 5 | protected context: RememberNode 6 | 7 | /** @internal */ 8 | public _isMutable = false 9 | 10 | constructor(val: T, context: RememberNode) { 11 | this._value = val 12 | this.context = context 13 | } 14 | 15 | public get value() { 16 | return this._value 17 | } 18 | 19 | /** @internal */ 20 | public switchContext(context: RememberNode) { 21 | this.context = context 22 | } 23 | 24 | /** @internal */ 25 | public shouldBeSaved() { 26 | return typeof this._value !== 'function' 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/EffectNode.ts: -------------------------------------------------------------------------------- 1 | import { findRelativePath } from '../utils.js' 2 | import { WorkingNode } from './WorkingNode.js' 3 | 4 | export class EffectNode extends WorkingNode { 5 | public effect: (isRestoring: boolean) => (() => void) | void 6 | public effectCleanup?: () => void 7 | public keys: any[] 8 | 9 | public override drop(newSubtreeRoot: WorkingNode): void { 10 | super.drop(newSubtreeRoot) 11 | 12 | const thisPath = this.path.concat(this.id) 13 | const relativePath = findRelativePath(thisPath, newSubtreeRoot.path) 14 | 15 | if (relativePath !== null) { 16 | const nodeAtPath = newSubtreeRoot.getNodeFromPath(relativePath) 17 | if (nodeAtPath === null) { 18 | this.effectCleanup?.() 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-test", 3 | "version": "0.0.1", 4 | "description": "", 5 | "private": true, 6 | "main": "build/index.js", 7 | "types": "build/index.d.ts", 8 | "type": "module", 9 | "scripts": { 10 | "build": "yarn tsc", 11 | "start": "webpack" 12 | }, 13 | "devDependencies": { 14 | "@typescript-eslint/eslint-plugin": "^5.34.0", 15 | "@typescript-eslint/parser": "^5.34.0", 16 | "eslint": "^8.22.0", 17 | "prettier": "^2.7.1", 18 | "ts-loader": "^9.3.1", 19 | "ts-node": "^10.9.1", 20 | "typescript": "^4.7.4", 21 | "webpack": "^5.74.0", 22 | "webpack-cli": "^4.10.0" 23 | }, 24 | "dependencies": { 25 | "@zapp-framework/core": "*", 26 | "@zapp-framework/ui": "*", 27 | "@zapp-framework/web": "*" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/Application.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@zapp-framework/core' 2 | import { NavigatorData } from './Navigator.js' 3 | import { KeyValueStorage } from './KeyValueStorage.js' 4 | 5 | export function Application(config: ApplicationConfig) { 6 | const navigatorData: NavigatorData = { 7 | currentPage: 'index', 8 | navStack: [], 9 | savedStates: [], 10 | registeredCallbacks: [], 11 | shouldRestore: false, 12 | } 13 | 14 | App({ 15 | globalData: { 16 | _navigator: navigatorData, 17 | _keyValue: {}, 18 | }, 19 | onCreate(params: string) { 20 | KeyValueStorage.load() 21 | config.onInit?.(params) 22 | }, 23 | 24 | onDestroy() { 25 | config.onDestroy?.() 26 | KeyValueStorage.save() 27 | }, 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/registerHomeButtonEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { EventType } from '../EventNode.js' 2 | import { ViewNode } from '../ViewNode.js' 3 | import { WorkingTree } from '../WorkingTree.js' 4 | import { ButtonActions, getAction } from './ButtonActions.js' 5 | 6 | export function registerHomeButtonEventHandler(actions: ButtonActions) { 7 | const current = WorkingTree.current as ViewNode 8 | 9 | for (const handlerName in actions) { 10 | const handler = actions[handlerName] 11 | 12 | if (handler !== undefined) { 13 | const context = WorkingTree.event(current) 14 | 15 | context.handler = handler 16 | context.eventType = EventType.HomeButton 17 | context.buttonAction = getAction(handlerName) 18 | 19 | current.children.push(context) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/registerShortcutButtonEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { EventType } from '../EventNode.js' 2 | import { ViewNode } from '../ViewNode.js' 3 | import { WorkingTree } from '../WorkingTree.js' 4 | import { ButtonActions, getAction } from './ButtonActions.js' 5 | 6 | export function registerShortcutButtonEventHandler(actions: ButtonActions) { 7 | const current = WorkingTree.current as ViewNode 8 | 9 | for (const handlerName in actions) { 10 | const handler = actions[handlerName] 11 | 12 | if (handler !== undefined) { 13 | const context = WorkingTree.event(current) 14 | 15 | context.handler = handler 16 | context.eventType = EventType.ShortcutButton 17 | context.buttonAction = getAction(handlerName) 18 | 19 | current.children.push(context) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { "project": ["./tsconfig.json"] }, 6 | "plugins": ["@typescript-eslint"], 7 | "ignorePatterns": ["watch-test/**/*.js", "**/__tests__/**/*"], 8 | "rules": { 9 | "@typescript-eslint/strict-boolean-expressions": [ 10 | 2, 11 | { 12 | "allowString": false, 13 | "allowNumber": false 14 | } 15 | ], 16 | "@typescript-eslint/no-non-null-assertion": "off", 17 | "@typescript-eslint/ban-ts-comment": "off", 18 | "@typescript-eslint/no-empty-function": "off", 19 | "@typescript-eslint/no-this-alias": "off", 20 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], 21 | "curly": "error" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/ArcConfig.ts: -------------------------------------------------------------------------------- 1 | import { BaseConfigBuilder } from './BaseConfig.js' 2 | 3 | export function ArcConfig(id: string) { 4 | return new ArcConfigBuilder(id) 5 | } 6 | 7 | export class ArcConfigBuilder extends BaseConfigBuilder { 8 | constructor(id: string) { 9 | super(id) 10 | 11 | this.config.startAngle = 0 12 | this.config.endAngle = 360 13 | } 14 | 15 | public lineWidth(width: number) { 16 | this.config.lineWidth = width 17 | return this 18 | } 19 | 20 | public color(color: number) { 21 | this.config.borderColor = color 22 | return this 23 | } 24 | 25 | public startAngle(angle: number) { 26 | this.config.startAngle = angle 27 | return this 28 | } 29 | 30 | public endAngle(angle: number) { 31 | this.config.endAngle = angle 32 | return this 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/Divider.ts: -------------------------------------------------------------------------------- 1 | import { BaseConfigBuilder, Stack } from '@zapp-framework/core' 2 | import { Theme } from './Theme.js' 3 | 4 | export class DividerConfigBuilder extends BaseConfigBuilder { 5 | constructor(id: string) { 6 | super(id) 7 | this.config.background = Theme.outline 8 | } 9 | 10 | color(color: number) { 11 | this.config.background = color 12 | return this 13 | } 14 | } 15 | 16 | export function DividerConfig(id: string): DividerConfigBuilder { 17 | return new DividerConfigBuilder(id) 18 | } 19 | 20 | export function Divider(config: DividerConfigBuilder) { 21 | const rawConfig = config.build() 22 | 23 | if (rawConfig.width === undefined && rawConfig.fillWidth === undefined) { 24 | config.width(1) 25 | } 26 | 27 | if (rawConfig.height === undefined && rawConfig.height === undefined) { 28 | config.height(1) 29 | } 30 | 31 | Stack(config) 32 | } 33 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/RememberNode.ts: -------------------------------------------------------------------------------- 1 | import { RememberedValue } from './effects/RememberedValue.js' 2 | import { WorkingNode } from './WorkingNode.js' 3 | import { findRelativePath, isRememberValueMutable } from '../utils.js' 4 | 5 | export class RememberNode extends WorkingNode { 6 | public remembered: RememberedValue 7 | 8 | public override drop(newSubtreeRoot: WorkingNode): void { 9 | super.drop(newSubtreeRoot) 10 | 11 | if (isRememberValueMutable(this.remembered) && this.remembered.animation !== undefined) { 12 | const thisPath = this.path.concat(this.id) 13 | const relativePath = findRelativePath(thisPath, newSubtreeRoot.path) 14 | 15 | if (relativePath !== null) { 16 | const nodeAtPath = newSubtreeRoot.getNodeFromPath(relativePath) 17 | if (nodeAtPath === null) { 18 | this.remembered.animation.drop() 19 | } 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /@zapp-framework/web/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | __setViewManager, 3 | __setZappInterface, 4 | __setNavigator, 5 | __setApplicationImplementation, 6 | } from '@zapp-framework/core' 7 | import { WebViewManager } from './WebViewManager.js' 8 | import { ZappWeb } from './ZappWeb.js' 9 | import { HashNavigator } from './HashNavigator.js' 10 | import { Application } from './Application.js' 11 | 12 | export { rememberScrollPosition } from './rememberScrollPosition.js' 13 | 14 | const navigator = new HashNavigator() 15 | 16 | __setZappInterface(new ZappWeb()) 17 | __setViewManager(new WebViewManager()) 18 | __setNavigator(navigator) 19 | __setApplicationImplementation(Application) 20 | 21 | export function registerNavigationRoutes( 22 | startingRoute: string, 23 | routes: Record) => void> 24 | ) { 25 | navigator.register(startingRoute, routes) 26 | } 27 | 28 | // @ts-ignore 29 | window.px = (x: number) => x 30 | -------------------------------------------------------------------------------- /@zapp-framework/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zapp-framework/ui", 3 | "version": "0.1.0", 4 | "description": "", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Jakub Piasecki" 8 | }, 9 | "main": "build/index.js", 10 | "types": "build/index.d.ts", 11 | "type": "module", 12 | "scripts": { 13 | "build": "yarn tsc", 14 | "lint": "yarn eslint --ext .ts src/ && yarn prettier --check src/", 15 | "format": "prettier --write --list-different src/", 16 | "prepack": "yarn build" 17 | }, 18 | "files": [ 19 | "build", 20 | "README.md" 21 | ], 22 | "devDependencies": { 23 | "@typescript-eslint/eslint-plugin": "^5.34.0", 24 | "@typescript-eslint/parser": "^5.34.0", 25 | "@zapp-framework/core": "*", 26 | "eslint": "^8.22.0", 27 | "prettier": "^2.7.1", 28 | "ts-node": "^10.9.1", 29 | "typescript": "^4.7.4" 30 | }, 31 | "peerDependencies": { 32 | "@zapp-framework/core": "*" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /@zapp-framework/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zapp-framework/web", 3 | "version": "0.1.0", 4 | "description": "", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Jakub Piasecki" 8 | }, 9 | "main": "build/index.js", 10 | "types": "build/index.d.ts", 11 | "type": "module", 12 | "scripts": { 13 | "build": "yarn tsc", 14 | "lint": "yarn eslint --ext .ts src/ && yarn prettier --check src/", 15 | "format": "prettier --write --list-different src/", 16 | "prepack": "yarn build" 17 | }, 18 | "files": [ 19 | "build", 20 | "README.md" 21 | ], 22 | "devDependencies": { 23 | "@typescript-eslint/eslint-plugin": "^5.34.0", 24 | "@typescript-eslint/parser": "^5.34.0", 25 | "@zapp-framework/core": "*", 26 | "eslint": "^8.22.0", 27 | "prettier": "^2.7.1", 28 | "ts-node": "^10.9.1", 29 | "typescript": "^4.7.4" 30 | }, 31 | "peerDependencies": { 32 | "@zapp-framework/core": "*" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /@zapp-framework/watch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zapp-framework/watch", 3 | "version": "0.1.1", 4 | "description": "", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Jakub Piasecki" 8 | }, 9 | "main": "build/index.js", 10 | "types": "build/index.d.ts", 11 | "type": "module", 12 | "scripts": { 13 | "build": "yarn tsc", 14 | "lint": "yarn eslint --ext .ts src/ && yarn prettier --check src/", 15 | "format": "prettier --write --list-different src/", 16 | "prepack": "yarn build" 17 | }, 18 | "files": [ 19 | "build", 20 | "README.md" 21 | ], 22 | "devDependencies": { 23 | "@typescript-eslint/eslint-plugin": "^5.34.0", 24 | "@typescript-eslint/parser": "^5.34.0", 25 | "@zapp-framework/core": "*", 26 | "eslint": "^8.22.0", 27 | "prettier": "^2.7.1", 28 | "ts-node": "^10.9.1", 29 | "typescript": "^4.7.4" 30 | }, 31 | "peerDependencies": { 32 | "@zapp-framework/core": "*" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/run-core-tests.yml: -------------------------------------------------------------------------------- 1 | name: Test core 2 | on: 3 | pull_request: 4 | paths: 5 | - '.github/workflows/run-core-tests.yml' 6 | - '@zapp-framework/core/**' 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | concurrency: 15 | group: test-core-${{ github.ref }} 16 | cancel-in-progress: true 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | 21 | - name: Use Node.js 16 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 16 25 | cache: 'yarn' 26 | 27 | - uses: actions/cache@v3 28 | with: 29 | path: '**/node_modules' 30 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 31 | 32 | - name: Install packages 33 | run: yarn install --frozen-lockfile 34 | 35 | - name: Run tests 36 | working-directory: ./@zapp-framework/core 37 | run: yarn test 38 | -------------------------------------------------------------------------------- /@zapp-framework/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zapp-framework/core", 3 | "version": "0.1.0", 4 | "description": "", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Jakub Piasecki" 8 | }, 9 | "main": "build/index.js", 10 | "types": "build/index.d.ts", 11 | "type": "module", 12 | "scripts": { 13 | "test": "jest", 14 | "build": "yarn tsc", 15 | "lint": "yarn eslint --ext .ts src/ && yarn prettier --check src/", 16 | "format": "prettier --write --list-different src/", 17 | "prepack": "yarn build", 18 | "prepublishOnly": "yarn test" 19 | }, 20 | "files": [ 21 | "build", 22 | "README.md" 23 | ], 24 | "devDependencies": { 25 | "@types/jest": "^28.1.7", 26 | "@typescript-eslint/eslint-plugin": "^5.34.0", 27 | "@typescript-eslint/parser": "^5.34.0", 28 | "eslint": "^8.22.0", 29 | "jest": "^28.1.3", 30 | "jest-ts-webcompat-resolver": "^1.0.0", 31 | "prettier": "^2.7.1", 32 | "ts-jest": "^28.0.8", 33 | "ts-node": "^10.9.1", 34 | "typescript": "^4.7.4" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | __setViewManager, 3 | __setZappInterface, 4 | __setSimpleScreenImplementation, 5 | __setNavigator, 6 | __setApplicationImplementation, 7 | } from '@zapp-framework/core' 8 | import { viewManagerInstance } from './WatchViewManager.js' 9 | import { ZappWatch } from './ZappWatch.js' 10 | import { SimpleScreen } from './screens/SimpleScreen.js' 11 | import { navigatorInstance } from './Navigator.js' 12 | import { Application } from './Application.js' 13 | 14 | export { Direction } from './types.js' 15 | export { 16 | ScreenPager, 17 | PagerEntry, 18 | ScreenPagerConfigBuilder, 19 | ScreenPagerConfig, 20 | rememberCurrentPage, 21 | } from './screens/ScreenPager.js' 22 | export { ScrollableScreen, rememberScrollPosition } from './screens/ScrollableScreen.js' 23 | export { rememberSavable } from './KeyValueStorage.js' 24 | 25 | __setZappInterface(new ZappWatch()) 26 | __setViewManager(viewManagerInstance) 27 | __setSimpleScreenImplementation(SimpleScreen) 28 | __setNavigator(navigatorInstance) 29 | __setApplicationImplementation(Application) 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jakub Piasecki 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 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/renderer/DummyViewManager.ts: -------------------------------------------------------------------------------- 1 | import { RenderNode } from './RenderedTree.js' 2 | import { ViewManager } from './ViewManager.js' 3 | 4 | export class DummyViewManager extends ViewManager { 5 | private nextViewId = 0 6 | 7 | get screenWidth() { 8 | return 400 9 | } 10 | 11 | get screenHeight() { 12 | return 400 13 | } 14 | 15 | public createView(node: RenderNode) { 16 | console.log('create', node.id) 17 | 18 | return this.nextViewId++ 19 | } 20 | 21 | public dropView(node: RenderNode) { 22 | console.log('drop', node.id) 23 | } 24 | 25 | public updateView(previous: RenderNode, next: RenderNode) { 26 | console.log('update', next.id) 27 | } 28 | 29 | public getScrollOffset(): { x: number; y: number } { 30 | return { x: 0, y: 0 } 31 | } 32 | 33 | public measureText( 34 | text: string, 35 | _node: RenderNode, 36 | _availableWidth: number, 37 | _availableHeight: number 38 | ): { width: number; height: number } { 39 | return { 40 | width: text.length, 41 | height: 1, 42 | } 43 | } 44 | 45 | public isRTL(): boolean { 46 | return false 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/rememberLauncherForResult.ts: -------------------------------------------------------------------------------- 1 | import { ViewNode } from '../ViewNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { Navigator } from '../../Navigator.js' 4 | 5 | type CallbackType = (result: Record | undefined) => void 6 | 7 | interface LauncherForResult { 8 | launch: (params?: Record) => void 9 | } 10 | 11 | export function rememberLauncherForResult(page: string, callback: CallbackType): LauncherForResult { 12 | const current = WorkingTree.current as ViewNode 13 | const context = WorkingTree.remember(current) 14 | 15 | const result = Navigator.tryPoppingLauncherResult(page, context.path.concat(context.id)) 16 | if (result !== undefined && result.ready) { 17 | callback(result.result) 18 | } 19 | 20 | // we don't need to push created node to parent context as we only need the path to trigger 21 | // the correct callback when the opened screen finishes 22 | 23 | return { 24 | launch: (params: Record) => { 25 | Navigator.registerResultCallback(page, context.path.concat(context.id)) 26 | Navigator.navigate(page, params) 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /@zapp-framework/web/src/rememberScrollPosition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RememberedMutableValue, 3 | rememberObservable, 4 | PointerEventManager, 5 | } from '@zapp-framework/core' 6 | 7 | let rememberedValues: RememberedMutableValue[] = [] 8 | let previousScroll = 0 9 | 10 | export function tryUpdatingRememberedScrollPositions() { 11 | const currentScroll = window.scrollY 12 | if (previousScroll !== currentScroll) { 13 | PointerEventManager.cancelPointers() 14 | } 15 | 16 | let needsClear = false 17 | for (const val of rememberedValues) { 18 | val.value = currentScroll 19 | 20 | // @ts-ignore that's private in the core package 21 | needsClear = needsClear || val.context.isDropped 22 | } 23 | 24 | if (needsClear) { 25 | // @ts-ignore that's private in the core package 26 | rememberedValues = rememberedValues.filter((v) => !v.context.isDropped) 27 | } 28 | 29 | previousScroll = currentScroll 30 | } 31 | 32 | export function rememberScrollPosition(): RememberedMutableValue { 33 | const value = rememberObservable(window.scrollY, (prev, current) => { 34 | window.scrollTo(0, current) 35 | }) 36 | if (rememberedValues.indexOf(value) === -1) { 37 | rememberedValues.push(value) 38 | } 39 | return value 40 | } 41 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/rememberImmutable.ts: -------------------------------------------------------------------------------- 1 | import { findRelativePath } from '../../utils.js' 2 | import { RememberNode } from '../RememberNode.js' 3 | import { ViewNode } from '../ViewNode.js' 4 | import { WorkingTree } from '../WorkingTree.js' 5 | import { RememberedValue } from './RememberedValue.js' 6 | 7 | export function remember(value: T): RememberedValue { 8 | const current = WorkingTree.current as ViewNode 9 | const context = WorkingTree.remember(current) 10 | 11 | let savedRemembered: RememberedValue | null = null 12 | 13 | const path = findRelativePath(context.path, current.rememberedContext?.path)?.concat(context.id) 14 | if (path !== null && path !== undefined) { 15 | const rememberedNode = current.rememberedContext?.getNodeFromPath(path) ?? null 16 | 17 | if (rememberedNode instanceof RememberNode) { 18 | savedRemembered = rememberedNode.remembered 19 | // TODO: investigate context switching in remembered values more 20 | savedRemembered.switchContext(context) 21 | } 22 | } 23 | 24 | const result = savedRemembered === null ? new RememberedValue(value, context) : savedRemembered 25 | 26 | context.remembered = result 27 | 28 | current.children.push(context) 29 | 30 | return result 31 | } 32 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/Text.ts: -------------------------------------------------------------------------------- 1 | import { TextConfig, BareText } from '@zapp-framework/core' 2 | import { Theme } from './Theme.js' 3 | 4 | const nextColors: number[] = [] 5 | const nextFontSizes: number[] = [] 6 | 7 | const DEFAULT_TEXT_SIZE = 32 8 | 9 | export function Text(config: ReturnType, text: string) { 10 | const rawConfig = config.build() 11 | 12 | const nextColor = nextColors[nextColors.length - 1] 13 | if (rawConfig.textColor === undefined && nextColor !== null) { 14 | config.textColor(nextColor) 15 | } 16 | 17 | const nextFontSize = nextFontSizes[nextFontSizes.length - 1] 18 | if (rawConfig.textSize === undefined && nextFontSize !== null) { 19 | config.textSize(nextFontSize) 20 | } 21 | 22 | if (rawConfig.textSize === undefined) { 23 | config.textSize(DEFAULT_TEXT_SIZE) 24 | } 25 | if (rawConfig.textColor === undefined) { 26 | config.textColor(Theme.onBackground) 27 | } 28 | 29 | BareText(config, text) 30 | } 31 | 32 | export function pushTextColor(color: number) { 33 | nextColors.push(color) 34 | } 35 | 36 | export function popTextColor() { 37 | nextColors.pop() 38 | } 39 | 40 | export function pushTextSize(size: number) { 41 | nextFontSizes.push(size) 42 | } 43 | 44 | export function popTextSize() { 45 | nextFontSizes.pop() 46 | } 47 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/ZappInterface.ts: -------------------------------------------------------------------------------- 1 | import { ViewManager } from './renderer/ViewManager.js' 2 | 3 | let zappInstance: ZappInterface 4 | 5 | export enum Platform { 6 | Web, 7 | Watch, 8 | } 9 | 10 | export enum ScreenShape { 11 | Square, 12 | Round, 13 | } 14 | 15 | export function setZappInterface(zapp: ZappInterface) { 16 | zappInstance = zapp 17 | } 18 | 19 | export abstract class ZappInterface { 20 | public abstract startLoop(): void 21 | public abstract stopLoop(): void 22 | public abstract setValue(key: string, value: unknown): void 23 | public abstract getValue(key: string): unknown 24 | public abstract readonly platform: Platform 25 | public abstract readonly screenShape: ScreenShape 26 | } 27 | 28 | export const Zapp = { 29 | startLoop() { 30 | zappInstance.startLoop() 31 | }, 32 | stopLoop() { 33 | zappInstance.stopLoop() 34 | }, 35 | setValue(key: string, value: unknown) { 36 | zappInstance.setValue(key, value) 37 | }, 38 | getValue(key: string): unknown { 39 | return zappInstance.getValue(key) 40 | }, 41 | get platform() { 42 | return zappInstance.platform 43 | }, 44 | get screenShape() { 45 | return zappInstance.screenShape 46 | }, 47 | get screenWidth() { 48 | return ViewManager.screenWidth 49 | }, 50 | get screenHeight() { 51 | return ViewManager.screenHeight 52 | }, 53 | } 54 | -------------------------------------------------------------------------------- /watch-test/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "configVersion": "v2", 3 | "app": { 4 | "appId": 24726, 5 | "appName": "Empty", 6 | "appType": "app", 7 | "version": { 8 | "code": 1, 9 | "name": "1.0.1" 10 | }, 11 | "icon": "icon.png", 12 | "vender": "zepp", 13 | "description": "empty app" 14 | }, 15 | "permissions": ["device:os.local_storage"], 16 | "runtime": { 17 | "apiVersion": { 18 | "compatible": "1.0.0", 19 | "target": "1.0.1", 20 | "minVersion": "1.0.0" 21 | } 22 | }, 23 | "targets": { 24 | "480x480-gtr-3-pro": { 25 | "module": { 26 | "page": { 27 | "pages": [ 28 | "page/index", 29 | "page/page1", 30 | "page/page2", 31 | "page/page3", 32 | "page/picker", 33 | "page/pager", 34 | "page/scrollable" 35 | ] 36 | }, 37 | "app-side": { 38 | "path": "app-side/index" 39 | } 40 | }, 41 | "platforms": [ 42 | { 43 | "name": "gtr3-pro", 44 | "deviceSource": 229 45 | }, 46 | { 47 | "name": "gtr3-pro-w", 48 | "deviceSource": 230 49 | } 50 | ], 51 | "designWidth": 480 52 | } 53 | }, 54 | "i18n": { 55 | "en-US": { 56 | "appName": "Empty" 57 | } 58 | }, 59 | "defaultLanguage": "en-US" 60 | } 61 | -------------------------------------------------------------------------------- /web-test/src/CustomButton.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Custom, 3 | remember, 4 | Stack, 5 | StackConfig, 6 | StackAlignment, 7 | TextConfig, 8 | BareText, 9 | ColumnConfig, 10 | } from '@zapp-framework/core' 11 | import { ConfigBuilder } from '@zapp-framework/core/build/working_tree/props/Config' 12 | 13 | export function CustomButton(config: ConfigBuilder, text: string, onClick: () => void) { 14 | const id = config.build().id 15 | Custom(ColumnConfig(`${id}#wrapper`).padding(10), {}, () => { 16 | const defaultColor = 0x333333 17 | const pressedColor = 0x444444 18 | 19 | const pressed = remember(false) 20 | const background = remember(defaultColor) 21 | 22 | Stack( 23 | StackConfig(`${id}#background`) 24 | .alignment(StackAlignment.CenterStart) 25 | .padding(10, 20) 26 | .background(background.value) 27 | .cornerRadius(10) 28 | .merge(config) 29 | .onPointerDown(() => { 30 | pressed.value = true 31 | background.value = pressedColor 32 | }) 33 | .onPointerUp(() => { 34 | if (pressed.value) { 35 | pressed.value = false 36 | background.value = defaultColor 37 | 38 | onClick() 39 | } 40 | }) 41 | .onPointerLeave(() => { 42 | pressed.value = false 43 | background.value = defaultColor 44 | }), 45 | () => { 46 | BareText(TextConfig(`${id}#text`).textColor(0xffffff).textSize(20), text) 47 | } 48 | ) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/__tests__/PrefixTree.test.ts: -------------------------------------------------------------------------------- 1 | import { PrefixTree } from '../PrefixTree' 2 | 3 | test('Empty tree returns no paths', () => { 4 | const tree = new PrefixTree() 5 | const paths = tree.getPaths() 6 | expect(paths.length).toBe(0) 7 | }) 8 | 9 | test('Handles one node', () => { 10 | const tree = new PrefixTree() 11 | tree.addPath(['a']) 12 | const paths = tree.getPaths() 13 | expect(paths).toEqual([['a']]) 14 | }) 15 | 16 | test('Tree returns the same path', () => { 17 | const tree = new PrefixTree() 18 | tree.addPath(['a', 'b', 'c']) 19 | const paths = tree.getPaths() 20 | expect(paths).toEqual([['a', 'b', 'c']]) 21 | }) 22 | 23 | test('Tree returns the shortest path', () => { 24 | const tree = new PrefixTree() 25 | tree.addPath(['a', 'b', 'c', 'd', 'e']) 26 | tree.addPath(['a', 'b', 'c']) 27 | tree.addPath(['a', 'b']) 28 | const paths = tree.getPaths() 29 | expect(paths).toEqual([['a', 'b']]) 30 | }) 31 | 32 | test('Tree returns all paths', () => { 33 | const tree = new PrefixTree() 34 | tree.addPath(['a', 'b', 'c', 'd', 'e']) 35 | tree.addPath(['a', 'b', 'c', 'f', 'g']) 36 | tree.addPath(['a', 'h']) 37 | const paths = tree.getPaths() 38 | expect(paths).toEqual([ 39 | ['a', 'b', 'c', 'd', 'e'], 40 | ['a', 'b', 'c', 'f', 'g'], 41 | ['a', 'h'], 42 | ]) 43 | }) 44 | 45 | test('Tree gets cleared correctly', () => { 46 | const tree = new PrefixTree() 47 | tree.addPath(['a', 'b', 'c']) 48 | tree.clear() 49 | const paths = tree.getPaths() 50 | expect(paths).toEqual([]) 51 | }) 52 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/LayoutConfig.ts: -------------------------------------------------------------------------------- 1 | import { BaseConfigBuilder } from './BaseConfig.js' 2 | 3 | export class LayoutConfigBuilder extends BaseConfigBuilder { 4 | public padding(padding: number): this 5 | public padding(vertical: number, horizontal: number): this 6 | public padding(start: number, top: number, end: number, bottom: number): this 7 | public padding(start: number, top?: number, end?: number, bottom?: number) { 8 | if (top !== undefined && end !== undefined && bottom !== undefined) { 9 | this.config.padding = { 10 | start: start, 11 | top: top, 12 | end: end, 13 | bottom: bottom, 14 | } 15 | } else if (top !== undefined) { 16 | this.config.padding = { 17 | start: top, 18 | top: start, 19 | end: top, 20 | bottom: start, 21 | } 22 | } else { 23 | this.config.padding = { 24 | start: start, 25 | top: start, 26 | end: start, 27 | bottom: start, 28 | } 29 | } 30 | 31 | return this 32 | } 33 | 34 | public background(background: number) { 35 | this.config.background = background 36 | return this 37 | } 38 | 39 | public cornerRadius(radius: number) { 40 | this.config.cornerRadius = radius 41 | return this 42 | } 43 | 44 | public borderWidth(width: number) { 45 | this.config.borderWidth = width 46 | return this 47 | } 48 | 49 | public borderColor(color: number) { 50 | this.config.borderColor = color 51 | return this 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/renderer/RenderedTree.ts: -------------------------------------------------------------------------------- 1 | import { NodeType } from '../NodeType.js' 2 | import { ConfigType } from '../working_tree/props/types.js' 3 | import { CustomViewProps } from '../working_tree/views/Custom.js' 4 | 5 | export interface Layout { 6 | width: number 7 | height: number 8 | x: number 9 | y: number 10 | measured: boolean 11 | widthInferred: boolean 12 | heightInferred: boolean 13 | } 14 | 15 | export interface RenderNode { 16 | id: string 17 | type: NodeType 18 | config: ConfigType 19 | children: RenderNode[] 20 | view: unknown 21 | zIndex: number 22 | layout: Layout 23 | customViewProps?: CustomViewProps 24 | } 25 | 26 | export abstract class RenderedTree { 27 | public static current: RenderNode | null = null 28 | public static next: RenderNode | null = null 29 | 30 | public static hitTest( 31 | x: number, 32 | y: number, 33 | parent: RenderNode | null = RenderedTree.current 34 | ): RenderNode | null { 35 | // TODO: consider handling corner radius 36 | if ( 37 | parent === null || 38 | x < parent.layout.x || 39 | x >= parent.layout.x + parent.layout.width || 40 | y < parent.layout.y || 41 | y >= parent.layout.y + parent.layout.height 42 | ) { 43 | return null 44 | } 45 | 46 | for (let i = parent.children.length - 1; i >= 0; i--) { 47 | const result = RenderedTree.hitTest(x, y, parent.children[i]) 48 | 49 | if (result !== null) { 50 | return result 51 | } 52 | } 53 | 54 | return parent 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/views/Screen.ts: -------------------------------------------------------------------------------- 1 | import { ViewNode } from '../ViewNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { NodeType } from '../../NodeType.js' 4 | import { Zapp } from '../../ZappInterface.js' 5 | import { ConfigBuilder } from '../props/Config.js' 6 | 7 | export function ScreenBody( 8 | configBuilder: ConfigBuilder, 9 | body?: (params?: Record) => void 10 | ) { 11 | const config = configBuilder.build() 12 | if (config.background === undefined) { 13 | // @ts-ignore 14 | config.background = Zapp.getValue('zapp#theme')?.background ?? 0x000000 15 | } 16 | 17 | const current = WorkingTree.current as ViewNode 18 | 19 | const context = WorkingTree.create(current, { 20 | id: config.id, 21 | type: NodeType.Screen, 22 | config: config, 23 | body: body, 24 | }) 25 | 26 | current.children.push(context) 27 | 28 | WorkingTree.withContext(context, body) 29 | } 30 | 31 | let simpleScreenImplementation = ( 32 | configBuilder: ConfigBuilder, 33 | body?: (params?: Record) => void 34 | ) => { 35 | ScreenBody(configBuilder, body) 36 | } 37 | 38 | export function setSimpleScreenImplementation( 39 | implementation: (config: ConfigBuilder, body?: (params?: Record) => void) => void 40 | ) { 41 | simpleScreenImplementation = implementation 42 | } 43 | 44 | export function SimpleScreen( 45 | configBuilder: ConfigBuilder, 46 | body?: (params?: Record) => void 47 | ) { 48 | simpleScreenImplementation(configBuilder, body) 49 | } 50 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { RememberedMutableValue } from './working_tree/effects/RememberedMutableValue.js' 2 | import type { RememberedValue } from './working_tree/effects/RememberedValue.js' 3 | import type { RememberNode } from './working_tree/RememberNode.js' 4 | import type { ViewNode } from './working_tree/ViewNode.js' 5 | import type { WorkingNode } from './working_tree/WorkingNode.js' 6 | import { NodeType } from './NodeType.js' 7 | 8 | export function findRelativePath(child: string[], parent?: string[]) { 9 | if (parent === undefined) { 10 | return null 11 | } 12 | 13 | let relativePath: string[] | null = [] 14 | 15 | for (let i = 0; i < child.length; i++) { 16 | if (i < parent.length) { 17 | if (child[i] !== parent[i]) { 18 | relativePath = null 19 | break 20 | } 21 | } else { 22 | relativePath.push(child[i]) 23 | } 24 | } 25 | 26 | return relativePath 27 | } 28 | 29 | export function coerce(value: number, min: number, max: number) { 30 | return Math.min(Math.max(value, min), max) 31 | } 32 | 33 | export type RequireSome = Partial & Pick 34 | 35 | export function isViewNode(node: WorkingNode): node is ViewNode { 36 | return ( 37 | node.type !== NodeType.Remember && node.type !== NodeType.Effect && node.type !== NodeType.Event 38 | ) 39 | } 40 | 41 | export function isRememberNode(node: WorkingNode): node is RememberNode { 42 | return node.type === NodeType.Remember 43 | } 44 | 45 | export function isRememberValueMutable( 46 | value: RememberedValue 47 | ): value is RememberedMutableValue { 48 | return value._isMutable 49 | } 50 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/Navigator.ts: -------------------------------------------------------------------------------- 1 | export interface RegisteredCallback { 2 | targetPage: string 3 | callbackPath: string[] 4 | result?: Record 5 | ready: boolean 6 | } 7 | 8 | export interface NavigatorInterface { 9 | readonly currentPage: string 10 | navigate(route: string, params?: Record): void 11 | goBack(): void 12 | goHome(): void 13 | registerResultCallback(page: string, path: string[]): void 14 | tryPoppingLauncherResult(page: string, path: string[]): RegisteredCallback | undefined 15 | finishWithResult(params: Record): void 16 | } 17 | 18 | let navigator: NavigatorInterface 19 | 20 | export abstract class Navigator { 21 | public static get currentPage(): string { 22 | return navigator.currentPage 23 | } 24 | 25 | public static navigate(route: string, params?: Record): void { 26 | navigator.navigate(route, params) 27 | } 28 | 29 | public static goBack(): void { 30 | navigator.goBack() 31 | } 32 | 33 | public static goHome(): void { 34 | navigator.goHome() 35 | } 36 | 37 | public static registerResultCallback(page: string, path: string[]) { 38 | navigator.registerResultCallback(page, path) 39 | } 40 | 41 | public static tryPoppingLauncherResult( 42 | page: string, 43 | path: string[] 44 | ): RegisteredCallback | undefined { 45 | return navigator.tryPoppingLauncherResult(page, path) 46 | } 47 | 48 | public static finishWithResult(params: Record) { 49 | navigator.finishWithResult(params) 50 | } 51 | } 52 | 53 | export function setNavigator(navigatorInstance: NavigatorInterface): void { 54 | navigator = navigatorInstance 55 | } 56 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/sideEffect.ts: -------------------------------------------------------------------------------- 1 | import { EffectNode } from '../EffectNode.js' 2 | import { ViewNode } from '../ViewNode.js' 3 | import { WorkingTree } from '../WorkingTree.js' 4 | 5 | export function sideEffect(effect: (isRestoring: boolean) => (() => void) | void, ...keys: any) { 6 | const current = WorkingTree.current as ViewNode 7 | const context = WorkingTree.effect(current) 8 | 9 | const path = context.path.slice(current.rememberedContext?.path.length).concat(context.id) 10 | const rememberedNode = current.rememberedContext?.getNodeFromPath(path) ?? null 11 | 12 | if (rememberedNode instanceof EffectNode) { 13 | let relaunch = false 14 | 15 | if (keys.length !== rememberedNode.keys.length) { 16 | relaunch = true 17 | } else { 18 | for (let i = 0; i < keys.length; i++) { 19 | if (keys[i] !== rememberedNode.keys[i]) { 20 | relaunch = true 21 | break 22 | } 23 | } 24 | } 25 | 26 | if (relaunch) { 27 | rememberedNode.effectCleanup?.() 28 | 29 | context.effect = effect 30 | WorkingTree.withContext(context, () => { 31 | context.effectCleanup = effect(WorkingTree.isRestoringState()) as (() => void) | undefined 32 | }) 33 | context.keys = keys 34 | } else { 35 | context.effect = rememberedNode.effect 36 | context.effectCleanup = rememberedNode.effectCleanup 37 | context.keys = rememberedNode.keys 38 | } 39 | } else { 40 | context.effect = effect 41 | WorkingTree.withContext(context, () => { 42 | context.effectCleanup = effect(WorkingTree.isRestoringState()) as (() => void) | undefined 43 | }) 44 | context.keys = keys 45 | } 46 | 47 | current.children.push(context) 48 | } 49 | -------------------------------------------------------------------------------- /watch-test/page/picker.js: -------------------------------------------------------------------------------- 1 | import { rememberSavable } from '@zapp-framework/watch' 2 | import { 3 | SimpleScreen, 4 | Stack, 5 | StackConfig, 6 | StackAlignment, 7 | Config, 8 | BareText, 9 | TextConfig, 10 | remember, 11 | sideEffect, 12 | withTiming, 13 | Column, 14 | Row, 15 | RowConfig, 16 | Alignment, 17 | Arrangement, 18 | Easing, 19 | ColumnConfig, 20 | ArcConfig, 21 | Navigator, 22 | } from '@zapp-framework/core' 23 | import { 24 | Button, 25 | ButtonConfig, 26 | Text, 27 | RadioButton, 28 | RadioGroup, 29 | RadioGroupConfig, 30 | } from '@zapp-framework/ui' 31 | 32 | SimpleScreen(Config('screen'), (params) => { 33 | Column( 34 | ColumnConfig('wrapper').fillSize().arrangement(Arrangement.Center).alignment(Alignment.Center), 35 | () => { 36 | const selected = rememberSavable('number', 1) 37 | 38 | RadioGroup( 39 | RadioGroupConfig('radio') 40 | .selected(selected.value - 1) 41 | .onChange((v) => { 42 | selected.value = v + 1 43 | }), 44 | () => { 45 | Column(ColumnConfig('radiowrapper').padding(0, 0, 0, 24), () => { 46 | RadioButton(Config('radio1'), () => { 47 | Text(TextConfig('radio1text'), 'Item 1') 48 | }) 49 | RadioButton(Config('radio2'), () => { 50 | Text(TextConfig('radio2text'), 'Item 2') 51 | }) 52 | RadioButton(Config('radio3'), () => { 53 | Text(TextConfig('radio3text'), 'Item 3') 54 | }) 55 | }) 56 | } 57 | ) 58 | 59 | Button( 60 | ButtonConfig('button').onPress(() => { 61 | Navigator.finishWithResult(selected.value) 62 | }), 63 | () => { 64 | Text(TextConfig('buttontext'), 'Ok') 65 | } 66 | ) 67 | } 68 | ) 69 | }) 70 | -------------------------------------------------------------------------------- /watch-test/page/page3.js: -------------------------------------------------------------------------------- 1 | import '@zapp-framework/watch' 2 | import { 3 | SimpleScreen, 4 | Stack, 5 | StackConfig, 6 | StackAlignment, 7 | Config, 8 | BareText, 9 | TextConfig, 10 | remember, 11 | sideEffect, 12 | withTiming, 13 | Column, 14 | Row, 15 | RowConfig, 16 | Alignment, 17 | Arrangement, 18 | Easing, 19 | ColumnConfig, 20 | ArcConfig, 21 | Navigator, 22 | rememberLauncherForResult, 23 | registerCrownEventHandler, 24 | } from '@zapp-framework/core' 25 | 26 | SimpleScreen(Config('screen'), (params) => { 27 | const selectedNumber = remember(-1) 28 | const launcher = rememberLauncherForResult('page/picker', (result) => { 29 | selectedNumber.value = result 30 | }) 31 | 32 | Stack( 33 | StackConfig('stack') 34 | .fillSize() 35 | .alignment(StackAlignment.Center) 36 | .background(0x0000ff) 37 | .onPointerUp(() => { 38 | launcher.launch() 39 | }), 40 | () => { 41 | const height = remember(10) 42 | const targetHeight = remember(10) 43 | 44 | registerCrownEventHandler((delta) => { 45 | targetHeight.value = Math.max(10, targetHeight.value + delta * -1) 46 | height.value = withTiming(targetHeight.value, { easing: Easing.easeOutCubic }) 47 | return true 48 | }) 49 | 50 | Column( 51 | ColumnConfig('column2').alignment(Alignment.Center).arrangement(Arrangement.Center), 52 | () => { 53 | Stack(StackConfig('bar').width(50).height(height.value).background(0xff0000)) 54 | } 55 | ) 56 | 57 | Column(ColumnConfig('column'), () => { 58 | BareText(TextConfig('text').textColor(0xffffff).textSize(40), `3, ${params.data}`) 59 | BareText( 60 | TextConfig('text2').textColor(0xffffff).textSize(40), 61 | `Selected: ${selectedNumber.value}` 62 | ) 63 | }) 64 | } 65 | ) 66 | }) 67 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/ViewNode.ts: -------------------------------------------------------------------------------- 1 | import { NodeType } from '../NodeType.js' 2 | import { ConfigType } from './props/types' 3 | import { WorkingNode, WorkingNodeProps } from './WorkingNode.js' 4 | import { findRelativePath } from '../utils.js' 5 | import { CustomViewProps } from './views/Custom.js' 6 | 7 | export interface ViewNodeProps extends WorkingNodeProps { 8 | body?: () => void 9 | config: ConfigType 10 | } 11 | 12 | export class ViewNode extends WorkingNode { 13 | public body?: () => void 14 | public children: WorkingNode[] 15 | public config: ConfigType 16 | 17 | // used for remembering values and during recomposing 18 | public override: ViewNode | undefined 19 | public nextActionId: number 20 | public rememberedContext: ViewNode | undefined 21 | 22 | // custom view properties 23 | public customViewProps?: CustomViewProps 24 | 25 | constructor(props: ViewNodeProps) { 26 | super(props) 27 | 28 | this.body = props.body 29 | this.children = [] 30 | this.config = props.config 31 | 32 | this.nextActionId = 0 33 | } 34 | 35 | public override reset(): void { 36 | this.rememberedContext = undefined 37 | this.override = undefined 38 | } 39 | 40 | public override drop(newSubtreeRoot: WorkingNode): void { 41 | super.drop(newSubtreeRoot) 42 | 43 | if (this.type === NodeType.Custom && this.customViewProps?.dropHandler !== undefined) { 44 | const thisPath = this.path.concat(this.id) 45 | const relativePath = findRelativePath(thisPath, newSubtreeRoot.path) 46 | 47 | if (relativePath !== null) { 48 | const nodeAtPath = newSubtreeRoot.getNodeFromPath(relativePath) 49 | if (nodeAtPath === null) { 50 | this.customViewProps.dropHandler() 51 | } 52 | } 53 | } 54 | 55 | for (const child of this.children) { 56 | child.drop(newSubtreeRoot) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/views/Custom.ts: -------------------------------------------------------------------------------- 1 | import { ViewNode } from '../ViewNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { NodeType } from '../../NodeType.js' 4 | import { RenderNode } from '../../renderer/RenderedTree.js' 5 | import { ConfigBuilder } from '../props/Config.js' 6 | 7 | export interface CustomViewProps extends Record { 8 | /** 9 | * invoked when the node is dropped from the tree 10 | */ 11 | dropHandler?: () => void 12 | 13 | /** 14 | * invoked when the view enters the view hierarchy, when implemented itmust return a 15 | * reference to the created view 16 | */ 17 | createView?: (config: RenderNode) => unknown 18 | 19 | /** 20 | * invoked after the ViewManager finishes setting up the newly created view, it may 21 | * be used to overwrite them 22 | */ 23 | overrideViewProps?: (config: RenderNode, view: unknown) => void 24 | 25 | /** 26 | * invoked during update, receives previous and the next node 27 | */ 28 | updateView?: (previous: RenderNode, next: RenderNode, view: unknown) => void 29 | 30 | /** 31 | * invoked when the view leaves the view hierarchy, when implemented it must delete the created 32 | * view (note that this method may be called without calling dropHandler to reorder views) 33 | */ 34 | deleteView?: (view: unknown) => void 35 | } 36 | 37 | export function Custom( 38 | configBuilder: ConfigBuilder, 39 | customViewProps: CustomViewProps, 40 | body?: () => void 41 | ) { 42 | const config = configBuilder.build() 43 | const current = WorkingTree.current as ViewNode 44 | 45 | const context = WorkingTree.create( 46 | current, 47 | { 48 | id: config.id, 49 | type: NodeType.Custom, 50 | config: config, 51 | body: body, 52 | }, 53 | customViewProps 54 | ) 55 | 56 | current.children.push(context) 57 | 58 | WorkingTree.withContext(context, body) 59 | } 60 | -------------------------------------------------------------------------------- /watch-test/page/page1.js: -------------------------------------------------------------------------------- 1 | import '@zapp-framework/watch' 2 | import { 3 | SimpleScreen, 4 | Stack, 5 | StackConfig, 6 | StackAlignment, 7 | Config, 8 | BareText, 9 | TextConfig, 10 | remember, 11 | sideEffect, 12 | withTiming, 13 | Column, 14 | Row, 15 | RowConfig, 16 | Alignment, 17 | Arrangement, 18 | Easing, 19 | ColumnConfig, 20 | ArcConfig, 21 | Navigator, 22 | registerGestureEventHandler, 23 | } from '@zapp-framework/core' 24 | 25 | SimpleScreen(Config('screen'), (params) => { 26 | Column( 27 | ColumnConfig('stack') 28 | .fillSize() 29 | .alignment(Alignment.Center) 30 | .arrangement(Arrangement.SpaceEvenly) 31 | .background(0xff0000), 32 | () => { 33 | const lastGesture = remember('') 34 | 35 | registerGestureEventHandler((gesture) => { 36 | lastGesture.value = gesture 37 | return true 38 | }) 39 | 40 | BareText(TextConfig('text').textColor(0xffffff).textSize(40), `1, ${params.data}`) 41 | BareText(TextConfig('text2').textColor(0xffffff).textSize(40), lastGesture.value) 42 | 43 | Stack( 44 | StackConfig('button1') 45 | .width(200) 46 | .height(50) 47 | .background(0xaaaaff) 48 | .onPointerDown(() => { 49 | Navigator.navigate('page/scrollable', { data: 'from 1' }) 50 | }) 51 | ) 52 | 53 | Stack( 54 | StackConfig('button2') 55 | .width(200) 56 | .height(50) 57 | .background(0xaaaaaa) 58 | .onPointerDown(() => { 59 | Navigator.navigate('page/page2', { data: 'from 1' }) 60 | }) 61 | ) 62 | 63 | Stack( 64 | StackConfig('button3') 65 | .width(200) 66 | .height(50) 67 | .background(0x000000) 68 | .onPointerDown(() => { 69 | Navigator.goBack() 70 | }) 71 | ) 72 | } 73 | ) 74 | }) 75 | -------------------------------------------------------------------------------- /web-test/src/NavBar.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Stack, 4 | StackAlignment, 5 | StackConfig, 6 | BareText, 7 | TextConfig, 8 | Custom, 9 | remember, 10 | ColumnConfig, 11 | Navigator, 12 | } from '@zapp-framework/core' 13 | 14 | function NavButton(text: string, route: string) { 15 | Custom(ColumnConfig(`wrapperbutton#${route}`).padding(5, 0), {}, () => { 16 | const defaultColor = route === Navigator.currentPage ? 0x555555 : 0x333333 17 | const pressedColor = route === Navigator.currentPage ? 0x666666 : 0x444444 18 | 19 | const pressed = remember(false) 20 | const background = remember(defaultColor) 21 | 22 | Stack( 23 | StackConfig(`button#${route}`) 24 | .alignment(StackAlignment.CenterStart) 25 | .fillWidth(1) 26 | .padding(10, 20) 27 | .background(background.value) 28 | .cornerRadius(10) 29 | .onPointerDown(() => { 30 | pressed.value = true 31 | background.value = pressedColor 32 | }) 33 | .onPointerUp(() => { 34 | if (pressed.value) { 35 | pressed.value = false 36 | background.value = defaultColor 37 | 38 | Navigator.navigate(route, { from: Navigator.currentPage }) 39 | } 40 | }) 41 | .onPointerLeave(() => { 42 | pressed.value = false 43 | background.value = defaultColor 44 | }), 45 | () => { 46 | BareText(TextConfig(`buttontext#${route}`).textColor(0xffffff).textSize(20), text) 47 | } 48 | ) 49 | }) 50 | } 51 | 52 | export interface RouteInfo { 53 | displayName: string 54 | routeName: string 55 | } 56 | 57 | export function NavBar(routes: RouteInfo[]) { 58 | Column(ColumnConfig('nav-bar').fillHeight().width(300).background(0x222222).padding(10), () => { 59 | for (const { displayName, routeName } of routes) { 60 | NavButton(displayName, routeName) 61 | } 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/PrefixTree.ts: -------------------------------------------------------------------------------- 1 | interface Node { 2 | value: string 3 | last: boolean 4 | children: Map 5 | } 6 | 7 | export class PrefixTree { 8 | private root: Node | undefined 9 | 10 | public addPath(path: string[]) { 11 | const key = path.shift() 12 | 13 | if (this.root === undefined && key !== undefined) { 14 | this.root = { 15 | value: key, 16 | last: path.length === 0, 17 | children: new Map(), 18 | } 19 | } 20 | 21 | if (path.length > 0) { 22 | this.appendToNode(this.root!, path) 23 | } 24 | } 25 | 26 | private appendToNode(node: Node, path: string[]) { 27 | const key = path.shift() 28 | if (key !== undefined) { 29 | if (node.children.get(key) === undefined) { 30 | node.children.set(key, { 31 | value: key, 32 | last: path.length === 0, 33 | children: new Map(), 34 | }) 35 | } 36 | 37 | if (path.length > 0) { 38 | this.appendToNode(node.children.get(key)!, path) 39 | } else { 40 | node.children.get(key)!.last = true 41 | } 42 | } 43 | } 44 | 45 | public getPaths(): string[][] { 46 | if (this.root === undefined) { 47 | return [] 48 | } 49 | 50 | return this.getNodePaths(this.root) 51 | } 52 | 53 | private getNodePaths(node: Node): string[][] { 54 | if (node.last || node.children.size === 0) { 55 | return [[node.value]] 56 | } 57 | 58 | const result: string[][] = [] 59 | 60 | for (const [_, child] of node.children) { 61 | const childPaths = this.getNodePaths(child) 62 | 63 | childPaths.forEach((path) => { 64 | path.unshift(node.value) 65 | 66 | result.push(path) 67 | }) 68 | } 69 | 70 | return result 71 | } 72 | 73 | public clear() { 74 | this.root = undefined 75 | } 76 | 77 | public isEmpty() { 78 | return this.root === undefined 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/ActivityIndicator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arc, 3 | ArcConfig, 4 | Config, 5 | ConfigBuilder, 6 | ConfigType, 7 | remember, 8 | sideEffect, 9 | Stack, 10 | withRepeat, 11 | withTiming, 12 | } from '@zapp-framework/core' 13 | import { Theme } from './Theme.js' 14 | 15 | interface ActivityIndicatorConfigType extends ConfigType { 16 | size: number 17 | } 18 | 19 | export class ActivityIndicatorConfigBuilder extends ConfigBuilder { 20 | protected config: ActivityIndicatorConfigType 21 | 22 | constructor(id: string) { 23 | super(id) 24 | this.config.size = px(80) 25 | this.config.lineWidth = px(10) 26 | } 27 | 28 | lineWidth(lineWidth: number) { 29 | this.config.lineWidth = lineWidth 30 | return this 31 | } 32 | 33 | size(size: number) { 34 | this.config.size = size 35 | return this 36 | } 37 | 38 | build(): ActivityIndicatorConfigType { 39 | return this.config 40 | } 41 | } 42 | 43 | export function ActivityIndicatorConfig(id: string): ActivityIndicatorConfigBuilder { 44 | return new ActivityIndicatorConfigBuilder(id) 45 | } 46 | 47 | export function ActivityIndicator(config: ActivityIndicatorConfigBuilder) { 48 | const rawConfig = config.build() 49 | 50 | Stack( 51 | Config(`${rawConfig.id}#wrapper`).positionAbsolutely(rawConfig.isPositionedAbsolutely!), 52 | () => { 53 | const angle = remember(-90) 54 | const size = remember(30) 55 | 56 | sideEffect(() => { 57 | angle.value = withRepeat(withTiming(270, { duration: 1000 })) 58 | size.value = withRepeat(withTiming(120, { duration: 500 }), { reverse: true }) 59 | }) 60 | 61 | Arc( 62 | ArcConfig(rawConfig.id) 63 | .startAngle(angle.value) 64 | .endAngle(angle.value + size.value) 65 | .width(rawConfig.size) 66 | .height(rawConfig.size) 67 | .lineWidth(rawConfig.lineWidth!) 68 | .color(Theme.primary) 69 | ) 70 | } 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/renderer/ViewManager.ts: -------------------------------------------------------------------------------- 1 | import { RenderNode } from './RenderedTree.js' 2 | 3 | let viewManagerInstance: ViewManagerInterface 4 | 5 | export function setViewManager(viewManager: ViewManagerInterface) { 6 | viewManagerInstance = viewManager 7 | } 8 | 9 | export abstract class ViewManagerInterface { 10 | abstract readonly screenWidth: number 11 | abstract readonly screenHeight: number 12 | 13 | public abstract createView(node: RenderNode): unknown 14 | 15 | public abstract dropView(node: RenderNode): void 16 | 17 | public abstract updateView(previous: RenderNode, next: RenderNode): void 18 | 19 | public abstract getScrollOffset(): { x: number; y: number } 20 | 21 | public abstract measureText( 22 | text: string, 23 | node: RenderNode, 24 | availableWidth: number, 25 | availableHeight: number 26 | ): { width: number; height: number } 27 | 28 | public abstract isRTL(): boolean 29 | } 30 | 31 | export abstract class ViewManager extends ViewManagerInterface { 32 | static get screenWidth() { 33 | return viewManagerInstance.screenWidth 34 | } 35 | 36 | static get screenHeight() { 37 | return viewManagerInstance.screenHeight 38 | } 39 | 40 | public static createView(node: RenderNode): unknown { 41 | return viewManagerInstance.createView(node) 42 | } 43 | 44 | public static dropView(node: RenderNode): void { 45 | viewManagerInstance.dropView(node) 46 | } 47 | 48 | public static updateView(previous: RenderNode, next: RenderNode): void { 49 | viewManagerInstance.updateView(previous, next) 50 | } 51 | 52 | public static getScrollOffset(): { x: number; y: number } { 53 | return viewManagerInstance.getScrollOffset() 54 | } 55 | 56 | public static measureText( 57 | text: string, 58 | node: RenderNode, 59 | availableWidth: number, 60 | availableHeight: number 61 | ): { width: number; height: number } { 62 | return viewManagerInstance.measureText(text, node, availableWidth, availableHeight) 63 | } 64 | 65 | public static isRTL(): boolean { 66 | return viewManagerInstance.isRTL() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /watch-test/page/page2.js: -------------------------------------------------------------------------------- 1 | import '@zapp-framework/watch' 2 | import { 3 | SimpleScreen, 4 | Stack, 5 | StackConfig, 6 | StackAlignment, 7 | Config, 8 | BareText, 9 | TextConfig, 10 | remember, 11 | sideEffect, 12 | withTiming, 13 | Column, 14 | Row, 15 | RowConfig, 16 | Alignment, 17 | Arrangement, 18 | Easing, 19 | ColumnConfig, 20 | ArcConfig, 21 | Navigator, 22 | registerHomeButtonEventHandler, 23 | registerShortcutButtonEventHandler, 24 | } from '@zapp-framework/core' 25 | 26 | SimpleScreen(Config('screen'), (params) => { 27 | Column( 28 | ColumnConfig('stack') 29 | .fillSize() 30 | .alignment(Alignment.Center) 31 | .arrangement(Arrangement.SpaceEvenly) 32 | .background(0x00ff00), 33 | () => { 34 | const text = remember('') 35 | 36 | BareText(TextConfig('text').textColor(0xffffff).textSize(40), `2, ${params.data}`) 37 | BareText(TextConfig('text2').textColor(0xffffff).textSize(40), `${text.value}`) 38 | 39 | registerHomeButtonEventHandler({ 40 | onClick: () => { 41 | text.value = 'home click' 42 | return true 43 | }, 44 | onLongPress: () => { 45 | text.value = 'home longpress' 46 | return true 47 | }, 48 | }) 49 | 50 | registerShortcutButtonEventHandler({ 51 | onClick: () => { 52 | text.value = 'shortcut click' 53 | return true 54 | }, 55 | onLongPress: () => { 56 | text.value = 'shortcut longpress' 57 | return true 58 | }, 59 | }) 60 | 61 | Stack( 62 | StackConfig('button') 63 | .width(200) 64 | .height(50) 65 | .background(0xaaaaaa) 66 | .onPointerDown(() => { 67 | Navigator.navigate('page/page3', { data: 'from 2' }) 68 | }) 69 | ) 70 | 71 | Stack( 72 | StackConfig('button2') 73 | .width(200) 74 | .height(50) 75 | .background(0x000000) 76 | .onPointerDown(() => { 77 | Navigator.goBack() 78 | }) 79 | ) 80 | } 81 | ) 82 | }) 83 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/screens/ScrollableScreen.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ScreenBody, 3 | ConfigBuilder, 4 | RememberedMutableValue, 5 | rememberObservable, 6 | PointerEventManager, 7 | } from '@zapp-framework/core' 8 | import { PageWrapper } from './PageWrapper.js' 9 | import { viewManagerInstance } from './../WatchViewManager.js' 10 | import { navigatorInstance } from '../Navigator.js' 11 | 12 | let rememberedValues: RememberedMutableValue[] = [] 13 | let previousScroll = 0 14 | 15 | export function ScrollableScreen( 16 | configBuilder: ConfigBuilder, 17 | body?: (params?: Record) => void 18 | ) { 19 | PageWrapper({ 20 | build: (params) => { 21 | ScreenBody(configBuilder, () => { 22 | body?.(params) 23 | }) 24 | }, 25 | initialize: () => { 26 | hmUI.setLayerScrolling(true) 27 | 28 | viewManagerInstance.setFreeScrolling() 29 | }, 30 | destroy: () => { 31 | rememberedValues = [] 32 | }, 33 | 34 | restoreState: (scrollPosition: number) => { 35 | hmApp.setLayerY(-scrollPosition) 36 | }, 37 | }) 38 | } 39 | 40 | export function tryUpdatingRememberedScrollPositions() { 41 | const currentScroll = -hmApp.getLayerY() 42 | if (previousScroll !== currentScroll) { 43 | navigatorInstance.saveScreenState(currentScroll) 44 | PointerEventManager.cancelPointers() 45 | } 46 | 47 | let needsClear = false 48 | for (const val of rememberedValues) { 49 | val.value = currentScroll 50 | 51 | // @ts-ignore that's private in the core package 52 | needsClear = needsClear || val.context.isDropped 53 | } 54 | 55 | if (needsClear) { 56 | // @ts-ignore that's private in the core package 57 | rememberedValues = rememberedValues.filter((v) => !v.context.isDropped) 58 | } 59 | 60 | previousScroll = currentScroll 61 | } 62 | 63 | export function rememberScrollPosition(): RememberedMutableValue { 64 | const value = rememberObservable(hmApp.getLayerY(), (prev, current) => { 65 | hmApp.setLayerY(-current) 66 | }) 67 | if (rememberedValues.indexOf(value) === -1) { 68 | rememberedValues.push(value) 69 | } 70 | return value 71 | } 72 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/ZappWatch.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ZappInterface, 3 | Platform, 4 | ScreenShape, 5 | WorkingTree, 6 | PointerEventManager, 7 | Renderer, 8 | Animation, 9 | GlobalEventManager, 10 | } from '@zapp-framework/core' 11 | import { tryUpdatingRememberedPagePositions } from './screens/ScreenPager.js' 12 | import { tryUpdatingRememberedScrollPositions } from './screens/ScrollableScreen.js' 13 | import { viewManagerInstance } from './WatchViewManager.js' 14 | 15 | export class ZappWatch extends ZappInterface { 16 | private timerRef: unknown 17 | private _screenShape: ScreenShape 18 | 19 | constructor() { 20 | super() 21 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 22 | this._screenShape = hmSetting.getDeviceInfo().screenShape 23 | ? ScreenShape.Round 24 | : ScreenShape.Square 25 | } 26 | 27 | public startLoop(): void { 28 | WorkingTree.requestUpdate() 29 | 30 | // @ts-ignore timer is in global scope on the watch 31 | this.timerRef = timer.createTimer(0, 16, this.update) 32 | } 33 | 34 | public stopLoop() { 35 | // @ts-ignore timer is in global scope on the watch 36 | timer.stopTimer(this.timerRef) 37 | } 38 | 39 | private update() { 40 | if (viewManagerInstance.isPaginated) { 41 | tryUpdatingRememberedPagePositions() 42 | } 43 | if (viewManagerInstance.isFreeScrolling) { 44 | tryUpdatingRememberedScrollPositions() 45 | } 46 | 47 | PointerEventManager.processEvents() 48 | GlobalEventManager.tick() 49 | Animation.nextFrame(Date.now()) 50 | 51 | if (WorkingTree.hasUpdates()) { 52 | WorkingTree.performUpdate() 53 | Renderer.commit(WorkingTree.root) 54 | Renderer.render() 55 | } 56 | } 57 | 58 | setValue(key: string, value: unknown): void { 59 | getApp()._options.globalData[key] = value 60 | } 61 | 62 | getValue(key: string): unknown { 63 | const app = getApp() 64 | if (app !== undefined) { 65 | return app._options.globalData[key] 66 | } 67 | } 68 | 69 | get platform() { 70 | return Platform.Watch 71 | } 72 | 73 | get screenShape() { 74 | return this._screenShape 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/Color.ts: -------------------------------------------------------------------------------- 1 | export abstract class Color { 2 | public static rgb(r: number, g: number, b: number): number { 3 | return (r << 16) + (g << 8) + b 4 | } 5 | 6 | public static hsl(h: number, s: number, l: number): number { 7 | const c = (1 - Math.abs(2 * l - 1)) * s 8 | const hPrime = h / 60 9 | const x = c * (1 - Math.abs((hPrime % 2) - 1)) 10 | const m = l - c / 2 11 | 12 | let rgb: number[] = [] 13 | 14 | if (hPrime >= 0 && hPrime < 1) { 15 | rgb = [c, x, 0] 16 | } else if (hPrime >= 1 && hPrime < 2) { 17 | rgb = [x, c, 0] 18 | } else if (hPrime >= 2 && hPrime < 3) { 19 | rgb = [0, c, x] 20 | } else if (hPrime >= 3 && hPrime < 4) { 21 | rgb = [0, x, c] 22 | } else if (hPrime >= 4 && hPrime < 5) { 23 | rgb = [x, 0, c] 24 | } else if (hPrime >= 5 && hPrime < 6) { 25 | rgb = [c, 0, x] 26 | } 27 | 28 | const [r, g, b] = rgb.map((v) => Math.floor((v + m) * 255)) 29 | 30 | return (r << 16) + (g << 8) + b 31 | } 32 | 33 | public static toRGB(color: number): [red: number, green: number, blue: number] { 34 | const r = (color & 0xff0000) >> 16 35 | const g = (color & 0x00ff00) >> 8 36 | const b = color & 0x0000ff 37 | 38 | return [r, g, b] 39 | } 40 | 41 | public static isDark(color: number): boolean { 42 | const [r, g, b] = this.toRGB(color) 43 | 44 | return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5 45 | } 46 | 47 | public static tint(color: number, factor: number): number { 48 | const [r, g, b] = this.toRGB(color) 49 | 50 | return this.rgb( 51 | Math.floor(r + (255 - r) * factor), 52 | Math.floor(g + (255 - g) * factor), 53 | Math.floor(b + (255 - b) * factor) 54 | ) 55 | } 56 | 57 | public static shade(color: number, factor: number): number { 58 | const [r, g, b] = this.toRGB(color) 59 | 60 | return this.rgb( 61 | Math.floor(r * (1 - factor)), 62 | Math.floor(g * (1 - factor)), 63 | Math.floor(b * (1 - factor)) 64 | ) 65 | } 66 | 67 | public static accent(color: number, factor: number): number { 68 | return this.isDark(color) ? this.tint(color, factor) : this.shade(color, factor) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/BaseConfig.ts: -------------------------------------------------------------------------------- 1 | import { ConfigBuilder } from './Config.js' 2 | import { ConfigType, PointerData } from './types.js' 3 | 4 | export function Config(id: string) { 5 | return new BaseConfigBuilder(id) 6 | } 7 | 8 | export class BaseConfigBuilder extends ConfigBuilder { 9 | protected config: ConfigType 10 | 11 | constructor(id: string) { 12 | super(id) 13 | } 14 | 15 | public build() { 16 | return this.config 17 | } 18 | 19 | public fillSize() { 20 | this.config.fillWidth = 1 21 | this.config.fillHeight = 1 22 | return this 23 | } 24 | 25 | public fillWidth(portion = 1) { 26 | this.config.fillWidth = portion 27 | return this 28 | } 29 | 30 | public fillHeight(portion = 1) { 31 | this.config.fillHeight = portion 32 | return this 33 | } 34 | 35 | public width(width: number) { 36 | this.config.width = width 37 | return this 38 | } 39 | 40 | public height(height: number) { 41 | this.config.height = height 42 | return this 43 | } 44 | 45 | public weight(weight: number) { 46 | this.config.weight = weight 47 | return this 48 | } 49 | 50 | public offset(x: number, y: number) { 51 | this.config.offsetX = x 52 | this.config.offsetY = y 53 | return this 54 | } 55 | 56 | public positionAbsolutely(value: boolean) { 57 | this.config.isPositionedAbsolutely = value 58 | return this 59 | } 60 | 61 | public onPointerDown(onPointerDown: (event: PointerData) => void) { 62 | this.config.onPointerDown = onPointerDown 63 | return this 64 | } 65 | 66 | public onPointerMove(onPointerMove: (event: PointerData) => void) { 67 | this.config.onPointerMove = onPointerMove 68 | return this 69 | } 70 | 71 | public onPointerUp(onPointerUp: (event: PointerData) => void) { 72 | this.config.onPointerUp = onPointerUp 73 | return this 74 | } 75 | 76 | public onPointerEnter(onPointerEnter: (event: PointerData) => void) { 77 | this.config.onPointerEnter = onPointerEnter 78 | return this 79 | } 80 | 81 | public onPointerLeave(onPointerLeave: (event: PointerData) => void) { 82 | this.config.onPointerLeave = onPointerLeave 83 | return this 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/props/types.ts: -------------------------------------------------------------------------------- 1 | import type { RenderNode } from '../../renderer/RenderedTree' 2 | 3 | export interface Padding { 4 | top: number 5 | bottom: number 6 | start: number 7 | end: number 8 | } 9 | 10 | export enum PointerEventType { 11 | DOWN, 12 | UP, 13 | MOVE, 14 | ENTER, 15 | LEAVE, 16 | } 17 | 18 | export interface PointerData { 19 | target: string 20 | id: number 21 | x: number 22 | y: number 23 | timestamp: number 24 | type: PointerEventType 25 | capture: () => void 26 | 27 | /** @internal */ 28 | realTargetView?: RenderNode 29 | } 30 | 31 | export interface ConfigType { 32 | id: string 33 | 34 | weight?: number 35 | background?: number 36 | cornerRadius?: number 37 | 38 | borderWidth?: number 39 | borderColor?: number 40 | 41 | lineWidth?: number 42 | startAngle?: number 43 | endAngle?: number 44 | 45 | width?: number 46 | fillWidth?: number 47 | 48 | height?: number 49 | fillHeight?: number 50 | 51 | padding?: Padding 52 | offsetX?: number 53 | offsetY?: number 54 | isPositionedAbsolutely?: boolean 55 | 56 | text?: string 57 | textSize?: number 58 | textColor?: number 59 | 60 | arrangement?: Arrangement 61 | alignment?: Alignment 62 | stackAlignment?: StackAlignment 63 | 64 | source?: string 65 | innerOffsetX?: number 66 | innerOffsetY?: number 67 | originX?: number 68 | originY?: number 69 | rotation?: number 70 | 71 | onPointerDown?: (event: PointerData) => void 72 | onPointerMove?: (event: PointerData) => void 73 | onPointerUp?: (event: PointerData) => void 74 | onPointerEnter?: (event: PointerData) => void 75 | onPointerLeave?: (event: PointerData) => void 76 | } 77 | 78 | /** 79 | * Positioning along the main axis 80 | */ 81 | export enum Arrangement { 82 | SpaceBetween, 83 | SpaceAround, 84 | SpaceEvenly, 85 | Start, 86 | Center, 87 | End, 88 | } 89 | 90 | /** 91 | * Positioning along the secondary axis 92 | */ 93 | export enum Alignment { 94 | Start, 95 | Center, 96 | End, 97 | } 98 | 99 | export enum StackAlignment { 100 | CenterStart, 101 | Center, 102 | CenterEnd, 103 | TopStart, 104 | TopCenter, 105 | TopEnd, 106 | BottomStart, 107 | BottomCenter, 108 | BottomEnd, 109 | } 110 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/animation/TimingAnimation.ts: -------------------------------------------------------------------------------- 1 | import { Animation, AnimationProps, AnimationType } from './Animation.js' 2 | import { coerce } from '../../../utils.js' 3 | import { Easing } from './Easing.js' 4 | 5 | const DEFAULT_DURATION = 300 6 | 7 | export interface TimingAnimationProps extends AnimationProps { 8 | duration?: number 9 | easing?: (t: number) => number 10 | } 11 | 12 | export class TimingAnimation extends Animation { 13 | private targetValue: number 14 | private duration: number 15 | private progress: number 16 | private easingFunction: (t: number) => number 17 | 18 | constructor(targetValue: number, props?: TimingAnimationProps) { 19 | super(props) 20 | 21 | this.progress = 0 22 | this.targetValue = targetValue 23 | this.duration = props?.duration ?? DEFAULT_DURATION 24 | this.easingFunction = props?.easing ?? Easing.linear 25 | } 26 | 27 | public calculateValue(timestamp: number): number { 28 | this.progress = coerce((timestamp - this.startTimestamp) / this.duration, 0, 1) 29 | 30 | if (this.progress === 1) { 31 | this.isFinished = true 32 | } 33 | 34 | return ( 35 | this.startValue + (this.targetValue - this.startValue) * this.easingFunction(this.progress) 36 | ) 37 | } 38 | 39 | public calculateReversedValue(value: number): number { 40 | return this.targetValue - value + this.startValue 41 | } 42 | 43 | public save(): Record | undefined { 44 | const result = super.save()! 45 | 46 | result.type = AnimationType.Timing 47 | result.targetValue = this.targetValue 48 | result.duration = this.duration 49 | result.progress = this.progress 50 | result.easingFunction = this.easingFunction 51 | 52 | return result 53 | } 54 | 55 | public static restore(from: Record): TimingAnimation { 56 | const result = new TimingAnimation(from.targetValue as number) 57 | 58 | result.startValue = from.startValue as number 59 | result.duration = from.duration as number 60 | result.progress = from.progress as number 61 | result.easingFunction = from.easingFunction as (t: number) => number 62 | result.startTimestamp = Date.now() - result.duration * result.progress 63 | 64 | return result 65 | } 66 | } 67 | 68 | export function withTiming(targetValue: number, props?: TimingAnimationProps) { 69 | return new TimingAnimation(targetValue, props) 70 | } 71 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/animation/Animation.ts: -------------------------------------------------------------------------------- 1 | import { RememberedMutableValue } from '../RememberedMutableValue.js' 2 | 3 | export interface AnimationProps { 4 | onEnd?: (completed: boolean) => void 5 | } 6 | 7 | export enum AnimationType { 8 | Timing, 9 | Repeat, 10 | } 11 | 12 | export abstract class Animation { 13 | protected static runningAnimations: Animation[] = [] 14 | 15 | public static nextFrame(timestamp: number) { 16 | for (const animation of Animation.runningAnimations) { 17 | animation.onFrame(timestamp) 18 | } 19 | 20 | Animation.runningAnimations = Animation.runningAnimations.filter((animation) => { 21 | if (!animation.isRunning && animation.rememberedValue.animation === animation) { 22 | animation.rememberedValue.animation = undefined 23 | } 24 | 25 | return animation.isRunning 26 | }) 27 | } 28 | 29 | // set when assigned to a remembered value 30 | public startValue: T 31 | public rememberedValue: RememberedMutableValue 32 | public isFinished = false 33 | 34 | protected startTimestamp: number 35 | protected endHandler?: (completed: boolean) => void 36 | protected isRunning = true 37 | 38 | constructor(props?: AnimationProps) { 39 | this.endHandler = props?.onEnd 40 | 41 | this.startTimestamp = Date.now() 42 | Animation.runningAnimations.push(this) 43 | } 44 | 45 | public abstract calculateValue(timestamp: number): T 46 | 47 | public calculateReversedValue(value: T) { 48 | return value 49 | } 50 | 51 | public onFrame(timestamp: number) { 52 | this.rememberedValue.value = this.calculateValue(timestamp) 53 | 54 | if (this.isFinished) { 55 | this.onEnd(true) 56 | } 57 | } 58 | 59 | public onEnd(completed: boolean) { 60 | if (this.isRunning) { 61 | this.isRunning = false 62 | this.endHandler?.(completed) 63 | } 64 | } 65 | 66 | public drop() { 67 | this.onEnd(false) 68 | 69 | const index = Animation.runningAnimations.indexOf(this) 70 | if (index !== -1) { 71 | Animation.runningAnimations.splice(index, 1) 72 | } 73 | } 74 | 75 | public reset() { 76 | this.startTimestamp = Date.now() 77 | this.isFinished = false 78 | this.isRunning = true 79 | } 80 | 81 | public inheritEndCallback(from: Animation) { 82 | this.endHandler = from.endHandler 83 | from.endHandler = undefined 84 | } 85 | 86 | public save(): Record | undefined { 87 | return { 88 | startValue: this.startValue, 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/rememberObservable.ts: -------------------------------------------------------------------------------- 1 | import { findRelativePath } from '../../utils.js' 2 | import { RememberNode } from '../RememberNode.js' 3 | import { ViewNode } from '../ViewNode.js' 4 | import { WorkingTree } from '../WorkingTree.js' 5 | import { RememberedMutableValue } from './RememberedMutableValue.js' 6 | import { Animation, AnimationType } from './animation/Animation.js' 7 | import { TimingAnimation } from './animation/TimingAnimation.js' 8 | import { RepeatAnimation } from './animation/RepeatAnimation.js' 9 | 10 | export function rememberObservable( 11 | value: T, 12 | observer?: (previous: T, current: T) => void, 13 | onValueRestored?: (value: T) => void 14 | ): RememberedMutableValue { 15 | const current = WorkingTree.current as ViewNode 16 | const context = WorkingTree.remember(current) 17 | 18 | let savedRemembered: RememberedMutableValue | null = null 19 | 20 | const restoredState = WorkingTree.root.savedState?.tryFindingValue?.(context) 21 | 22 | if (restoredState !== undefined) { 23 | value = restoredState.value as T 24 | 25 | onValueRestored?.(value) 26 | } else { 27 | const path = findRelativePath(context.path, current.rememberedContext?.path)?.concat(context.id) 28 | if (path !== null && path !== undefined) { 29 | const rememberedNode = current.rememberedContext?.getNodeFromPath(path) ?? null 30 | 31 | if ( 32 | rememberedNode instanceof RememberNode && 33 | rememberedNode.remembered instanceof RememberedMutableValue 34 | ) { 35 | savedRemembered = rememberedNode.remembered 36 | // TODO: investigate context switching in remembered values more 37 | savedRemembered.switchContext(context) 38 | } 39 | } 40 | } 41 | 42 | const result = 43 | savedRemembered === null ? new RememberedMutableValue(value, context) : savedRemembered 44 | 45 | if (restoredState !== undefined && restoredState.animationData !== undefined) { 46 | let anim: Animation | null = null 47 | 48 | if (restoredState.animationData.type === AnimationType.Timing) { 49 | anim = TimingAnimation.restore(restoredState.animationData) 50 | } else if (restoredState.animationData.type === AnimationType.Repeat) { 51 | anim = RepeatAnimation.restore(restoredState.animationData) 52 | } 53 | 54 | if (anim !== null) { 55 | // when restoring animation from saved state, assign directly 56 | anim.rememberedValue = result 57 | result.animation = anim as Animation 58 | } 59 | } 60 | 61 | result._observer = observer 62 | context.remembered = result 63 | current.children.push(context) 64 | 65 | return result 66 | } 67 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/KeyValueStorage.ts: -------------------------------------------------------------------------------- 1 | import { RememberedMutableValue, rememberObservable } from '@zapp-framework/core' 2 | 3 | function stringToArrayBuffer(str: string) { 4 | const buf = new ArrayBuffer(str.length * 2) // 2 bytes for each char 5 | const bufView = new Uint16Array(buf) 6 | for (let i = 0, strLen = str.length; i < strLen; i++) { 7 | bufView[i] = str.charCodeAt(i) 8 | } 9 | return buf 10 | } 11 | 12 | const FILE_NAME = 'zapp_key_value.txt' 13 | 14 | export abstract class KeyValueStorage { 15 | public static get values(): Record { 16 | return getApp()._options.globalData._keyValue 17 | } 18 | 19 | private static set values(data: Record) { 20 | getApp()._options.globalData._keyValue = data 21 | } 22 | 23 | public static save() { 24 | const file = hmFS.open(FILE_NAME, hmFS.O_CREAT | hmFS.O_RDWR | hmFS.O_TRUNC) 25 | const contentBuffer = stringToArrayBuffer(JSON.stringify(this.values)) 26 | 27 | hmFS.write(file, contentBuffer, 0, contentBuffer.byteLength) 28 | hmFS.close(file) 29 | } 30 | 31 | public static load() { 32 | const [fsStat, err] = hmFS.stat(FILE_NAME) 33 | if (err === 0) { 34 | const { size } = fsStat 35 | const fileContentUnit = new Uint16Array(new ArrayBuffer(size)) 36 | const file = hmFS.open(FILE_NAME, hmFS.O_RDONLY | hmFS.O_CREAT) 37 | hmFS.seek(file, 0, hmFS.SEEK_SET) 38 | hmFS.read(file, fileContentUnit.buffer, 0, size) 39 | hmFS.close(file) 40 | 41 | try { 42 | // @ts-ignore 43 | const val = String.fromCharCode.apply(null, fileContentUnit) 44 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 45 | KeyValueStorage.values = val ? JSON.parse(val) : {} 46 | } catch (error) { 47 | KeyValueStorage.values = {} 48 | } 49 | } 50 | } 51 | } 52 | 53 | const rememberedValues = new Map[]>() 54 | 55 | export function rememberSavable(key: string, defaultValue: T) { 56 | const value = rememberObservable(defaultValue, (previous, current) => { 57 | KeyValueStorage.values[key] = current 58 | 59 | const saved = rememberedValues.get(key) 60 | if (saved !== undefined) { 61 | for (const val of saved) { 62 | val.value = current 63 | } 64 | } 65 | }) 66 | 67 | const savedValue = KeyValueStorage.values[key] 68 | if (savedValue !== undefined) { 69 | value.value = savedValue as T 70 | } 71 | 72 | const saved = rememberedValues.get(key) ?? [] 73 | if (saved.indexOf(value) === -1) { 74 | saved.push(value) 75 | rememberedValues.set(key, saved) 76 | } 77 | 78 | return value 79 | } 80 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/WorkingNode.ts: -------------------------------------------------------------------------------- 1 | import { NodeType } from '../NodeType.js' 2 | 3 | export interface WorkingNodeProps { 4 | id: string 5 | type: NodeType 6 | } 7 | 8 | export abstract class WorkingNode { 9 | public id: string 10 | public type: NodeType 11 | public parent: WorkingNode | null 12 | public path: string[] 13 | public isDropped: boolean 14 | 15 | constructor(props: WorkingNodeProps) { 16 | this.id = props.id 17 | this.type = props.type 18 | this.parent = null 19 | this.path = [] 20 | this.isDropped = false 21 | } 22 | 23 | public getNodeFromPath(path: string[]): WorkingNode | null { 24 | let current = this 25 | let index = 0 26 | if (path[index] === current.id) { 27 | index++ // skip root 28 | } 29 | 30 | while (index < path.length) { 31 | const id = path[index++] 32 | // @ts-ignore children doesn't exist on remember and effect nodes, but those don't have children anyway 33 | const children = current.children ?? [] 34 | let found = false 35 | 36 | for (const node of children) { 37 | if (node.id === id) { 38 | current = node 39 | found = true 40 | break 41 | } 42 | } 43 | 44 | if (!found) { 45 | return null 46 | } 47 | } 48 | 49 | return current 50 | } 51 | 52 | public reset() {} 53 | 54 | public drop(_newSubtreeRoot: WorkingNode) { 55 | this.isDropped = true 56 | } 57 | 58 | public show() { 59 | console.log(this.toString()) 60 | } 61 | 62 | public toString() { 63 | return JSON.stringify( 64 | this, 65 | (k, v) => { 66 | switch (k) { 67 | case 'id': 68 | case 'type': 69 | case 'config': 70 | case 'rememberIndex': 71 | case 'keys': 72 | return v 73 | case 'path': 74 | return v.join('/') 75 | case 'body': 76 | return v !== undefined ? 'body' : 'no body' 77 | case 'override': 78 | return v !== undefined ? 'override' : 'no override' 79 | case 'parent': 80 | return v?.id ?? 'no parent' 81 | case 'remembered': 82 | return { value: v.value, animation: v._animation } 83 | case 'rememberedContext': 84 | return v !== undefined ? 'remembered context' : 'no remembered context' 85 | case 'effectCleanup': 86 | return v !== undefined ? 'cleanup' : 'no cleanup' 87 | case 'animation': 88 | return v !== undefined ? 'animation' : 'no animation' 89 | default: 90 | return v 91 | } 92 | }, 93 | 2 94 | ) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/RememberedMutableValue.ts: -------------------------------------------------------------------------------- 1 | import { RememberNode } from '../RememberNode.js' 2 | import { WorkingTree } from '../WorkingTree.js' 3 | import { RememberedValue } from './RememberedValue.js' 4 | import { Animation } from './animation/Animation.js' 5 | import { EffectNode } from '../EffectNode.js' 6 | 7 | export class RememberedMutableValue extends RememberedValue { 8 | private _animation?: Animation 9 | 10 | /** @internal */ 11 | public _isMutable = true 12 | 13 | /** @internal */ 14 | public _observer?: (previous: T, current: T) => void 15 | 16 | /** @internal */ 17 | get animation() { 18 | return this._animation 19 | } 20 | 21 | /** @internal */ 22 | set animation(animation: Animation | undefined) { 23 | this._animation = animation 24 | } 25 | 26 | public get value(): T { 27 | return this._value 28 | } 29 | 30 | public set value(newValue: T | Animation) { 31 | // skip assignment of the new value if we are currently restoring saved state and we are inside side effect 32 | const skipAssignment = 33 | WorkingTree.current instanceof EffectNode && WorkingTree.isRestoringState() 34 | 35 | if (newValue instanceof Animation) { 36 | if (skipAssignment && this._animation !== undefined) { 37 | // we need to recalculate the end callbacks so they have correct closure but we need to drop the 38 | // newly created animation 39 | this._animation.inheritEndCallback(newValue) 40 | newValue.drop() 41 | } else { 42 | if (this._animation !== undefined) { 43 | this._animation.drop() 44 | } 45 | this._animation = newValue 46 | 47 | // set starting value only when assigning a new animation in order not to override the restored one 48 | this._animation.startValue = this._value 49 | } 50 | 51 | this._animation.rememberedValue = this 52 | } else if (!skipAssignment) { 53 | const oldValue = this._value 54 | this._value = newValue 55 | 56 | if (this.context.isDropped) { 57 | const newNode = WorkingTree.root.getNodeFromPath(this.context.path.concat(this.context.id)) 58 | if (newNode !== null) { 59 | const remembered = (newNode as RememberNode).remembered as RememberedMutableValue 60 | remembered.value = newValue 61 | } 62 | } else if (oldValue !== newValue) { 63 | WorkingTree.queueUpdate(this.context.parent!) 64 | 65 | // invoke observer only if node is in active tree, otherwise the assignment is delegated to 66 | // the node in active tree, which should trigger its observer 67 | this._observer?.(oldValue, newValue) 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/animation/Easing.ts: -------------------------------------------------------------------------------- 1 | // most of the logic borrowed from https://github.com/software-mansion/react-native-reanimated/blob/3c8f7d013645e77d476004698981b6d526bdb806/src/reanimated2/Bezier.ts 2 | 3 | const NEWTON_ITERATIONS = 8 4 | const NEWTON_MIN_SLOPE = 0.001 5 | const SUBDIVISION_PRECISION = 0.001 6 | const SUBDIVISION_MAX_ITERATIONS = 10 7 | 8 | function bezier(t: number, p1: number, p2: number): number { 9 | return 3 * (1 - t) * (1 - t) * t * p1 + 3 * (1 - t) * t * t * p2 + t * t * t 10 | } 11 | 12 | function bezierSlope(t: number, p1: number, p2: number): number { 13 | return 3 * (1 - t) * (1 - t) * p1 + 6 * (1 - t) * t * (p2 - p1) + 3 * t * t * (1 - p2) 14 | } 15 | 16 | function newtonRaphsonIterate(x: number, t: number, x1: number, x2: number): number { 17 | for (let i = 0; i < NEWTON_ITERATIONS; ++i) { 18 | const currentSlope = bezierSlope(t, x1, x2) 19 | if (currentSlope === 0.0) { 20 | return t 21 | } 22 | const currentX = bezier(t, x1, x2) - x 23 | t -= currentX / currentSlope 24 | } 25 | return t 26 | } 27 | 28 | function binarySubdivide(x: number, a: number, b: number, x1: number, x2: number): number { 29 | let currentX 30 | let currentT 31 | let i = 0 32 | do { 33 | currentT = a + (b - a) / 2.0 34 | currentX = bezier(currentT, x1, x2) - x 35 | if (currentX > 0.0) { 36 | b = currentT 37 | } else { 38 | a = currentT 39 | } 40 | } while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS) 41 | return currentT 42 | } 43 | 44 | function getTForX(x: number, mX1: number, mX2: number): number { 45 | const initialSlope = bezierSlope(x, mX1, mX2) 46 | if (initialSlope >= NEWTON_MIN_SLOPE) { 47 | return newtonRaphsonIterate(x, x, mX1, mX2) 48 | } else if (initialSlope === 0.0) { 49 | return x 50 | } else { 51 | return binarySubdivide(x, 0, 1, mX1, mX2) 52 | } 53 | } 54 | 55 | function makeBezierEasing(x1: number, y1: number, x2: number, y2: number): (t: number) => number { 56 | return (t: number) => { 57 | if (t === 0) { 58 | return 0 59 | } 60 | if (t === 1) { 61 | return 1 62 | } 63 | return bezier(getTForX(t, x1, x2), y1, y2) 64 | } 65 | } 66 | 67 | export abstract class Easing { 68 | public static linear(t: number): number { 69 | return t 70 | } 71 | 72 | public static ease = makeBezierEasing(0.28, 0.17, 0.27, 1) 73 | public static easeInQuad = makeBezierEasing(0.11, 0, 0.5, 0) 74 | public static easeOutQuad = makeBezierEasing(0.5, 1, 0.89, 1) 75 | public static easeInOutQuad = makeBezierEasing(0.45, 0, 0.55, 1) 76 | public static easeInCubic = makeBezierEasing(0.32, 0, 0.67, 0) 77 | public static easeOutCubic = makeBezierEasing(0.33, 1, 0.68, 1) 78 | public static easeInOutCubic = makeBezierEasing(0.65, 0, 0.35, 1) 79 | } 80 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/Navigator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NavigatorInterface, 3 | RegisteredCallback, 4 | WorkingTree, 5 | SavedTreeState, 6 | } from '@zapp-framework/core' 7 | 8 | interface SavedState { 9 | treeState: SavedTreeState 10 | screenState: unknown 11 | } 12 | 13 | export interface NavigatorData { 14 | currentPage: string 15 | navStack: string[] 16 | savedStates: SavedState[] 17 | registeredCallbacks: RegisteredCallback[] 18 | shouldRestore: boolean 19 | } 20 | 21 | let savedScreenState: unknown = null 22 | 23 | export class Navigator implements NavigatorInterface { 24 | get currentPage(): string { 25 | return getApp()._options.globalData._navigator.currentPage 26 | } 27 | 28 | private get data(): NavigatorData { 29 | return getApp()._options.globalData._navigator 30 | } 31 | 32 | public navigate(page: string, params?: Record) { 33 | this.data.navStack.push(this.data.currentPage) 34 | this.data.currentPage = page 35 | this.data.savedStates.push({ 36 | treeState: WorkingTree.saveState(), 37 | screenState: savedScreenState, 38 | }) 39 | 40 | hmApp.gotoPage({ url: page, param: JSON.stringify(params ?? {}) }) 41 | } 42 | 43 | public goBack() { 44 | this.data.currentPage = this.data.navStack.pop()! 45 | this.data.shouldRestore = true 46 | 47 | hmApp.goBack() 48 | } 49 | 50 | public goHome() { 51 | hmApp.gotoHome() 52 | } 53 | 54 | public registerResultCallback(page: string, path: string[]): void { 55 | this.data.registeredCallbacks.push({ 56 | targetPage: page, 57 | callbackPath: path, 58 | ready: false, 59 | }) 60 | } 61 | 62 | public tryPoppingLauncherResult(page: string, path: string[]): RegisteredCallback | undefined { 63 | if (this.data.registeredCallbacks.length > 0) { 64 | const top = this.data.registeredCallbacks[this.data.registeredCallbacks.length - 1] 65 | 66 | if (top.ready && page === top.targetPage && top.callbackPath.length === path.length) { 67 | for (let i = 0; i < path.length; i++) { 68 | if (path[i] !== top.callbackPath[i]) { 69 | return undefined 70 | } 71 | } 72 | 73 | return this.data.registeredCallbacks.pop() 74 | } 75 | } 76 | 77 | return undefined 78 | } 79 | 80 | public finishWithResult(params: Record): void { 81 | if (this.data.registeredCallbacks.length > 0) { 82 | const top = this.data.registeredCallbacks[this.data.registeredCallbacks.length - 1] 83 | 84 | if (top.targetPage === this.currentPage) { 85 | top.ready = true 86 | top.result = params 87 | } 88 | } 89 | 90 | this.goBack() 91 | } 92 | 93 | public saveScreenState(state: unknown) { 94 | savedScreenState = state 95 | } 96 | } 97 | 98 | export const navigatorInstance = new Navigator() 99 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/SavedTreeState.ts: -------------------------------------------------------------------------------- 1 | import type { ViewNode } from './ViewNode.js' 2 | import type { RememberNode } from './RememberNode.js' 3 | import type { WorkingNode } from './WorkingNode.js' 4 | import { isViewNode, isRememberNode, isRememberValueMutable } from '../utils.js' 5 | 6 | interface SavedState { 7 | value?: unknown 8 | animationData?: Record 9 | } 10 | 11 | interface InnerNode { 12 | id: string 13 | children: Map 14 | } 15 | 16 | interface LeafNode { 17 | id: string 18 | state: SavedState 19 | } 20 | 21 | function isInnerNode(node: InnerNode | LeafNode): node is InnerNode { 22 | // @ts-ignore 23 | return node.children !== undefined 24 | } 25 | 26 | export class SavedTreeState { 27 | private root: InnerNode 28 | 29 | constructor(root: WorkingNode) { 30 | this.root = (this.createNode(root) ?? { id: root.id, children: new Map() }) as InnerNode 31 | } 32 | 33 | private createNode(node: WorkingNode): InnerNode | LeafNode | null { 34 | // dont't save nodes without children 35 | if (isViewNode(node) && node.children.length > 0) { 36 | const result: InnerNode = { 37 | id: node.id, 38 | children: new Map(), 39 | } 40 | 41 | const mappedChildren = node.children 42 | .map((child) => this.createNode(child)) 43 | .filter((child) => child !== null) 44 | 45 | // if there are no children to save, don't save this node 46 | if (mappedChildren.length > 0) { 47 | mappedChildren.forEach((child) => { 48 | result.children.set(child!.id, child!) 49 | }) 50 | 51 | return result 52 | } 53 | } else if (isRememberNode(node) && node.remembered.shouldBeSaved()) { 54 | const result: LeafNode = { 55 | id: node.id, 56 | state: { 57 | value: node.remembered.value, 58 | }, 59 | } 60 | 61 | if (isRememberValueMutable(node.remembered) && node.remembered.animation !== undefined) { 62 | result.state.animationData = node.remembered.animation.save() 63 | } 64 | 65 | return result 66 | } 67 | 68 | return null 69 | } 70 | 71 | public tryFindingValue(node: RememberNode): SavedState | undefined { 72 | let current = this.root 73 | let index = 0 74 | 75 | if (node.path[index] === current.id) { 76 | index++ // skip root 77 | } 78 | 79 | while (index < node.path.length) { 80 | const id = node.path[index++] 81 | const children = current.children 82 | 83 | if (children.has(id)) { 84 | const child = children.get(id) 85 | 86 | if (child !== undefined && isInnerNode(child)) { 87 | current = child 88 | } else { 89 | return undefined 90 | } 91 | } else { 92 | return undefined 93 | } 94 | } 95 | 96 | // @ts-ignore 97 | return current.children.get(node.id)?.state 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /@zapp-framework/web/src/HashNavigator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SavedTreeState, 3 | WorkingTree, 4 | NavigatorInterface, 5 | RegisteredCallback, 6 | } from '@zapp-framework/core' 7 | 8 | const historyStack: SavedTreeState[] = [] 9 | const registeredCallbacks: RegisteredCallback[] = [] 10 | 11 | // not using common interface for now due to platform specific differences 12 | export class HashNavigator implements NavigatorInterface { 13 | private routes: Record) => void> 14 | private currentRoute: string 15 | 16 | public register( 17 | startingRoute: string, 18 | routes: Record) => void> 19 | ) { 20 | const routeToRender = 21 | window.location.hash.length === 0 ? startingRoute : window.location.hash.substring(1) 22 | 23 | this.routes = routes 24 | this.changeRoute(routeToRender, history.state) 25 | history.replaceState(history.state, '', `#${routeToRender}`) 26 | 27 | window.addEventListener('popstate', (e) => { 28 | WorkingTree.restoreState(historyStack.pop()!) 29 | this.changeRoute(window.location.hash.substring(1), e.state) 30 | }) 31 | } 32 | 33 | public navigate(route: string, params?: Record) { 34 | if (this.routes[route] !== undefined) { 35 | historyStack.push(WorkingTree.saveState()) 36 | this.changeRoute(route, params) 37 | history.pushState(params, '', `#${route}`) 38 | } 39 | } 40 | 41 | public goBack(): void { 42 | history.back() 43 | } 44 | 45 | public goHome(): void {} 46 | 47 | public registerResultCallback(page: string, path: string[]): void { 48 | registeredCallbacks.push({ 49 | targetPage: page, 50 | callbackPath: path, 51 | ready: false, 52 | }) 53 | } 54 | 55 | public tryPoppingLauncherResult(page: string, path: string[]): RegisteredCallback | undefined { 56 | if (registeredCallbacks.length > 0) { 57 | const top = registeredCallbacks[registeredCallbacks.length - 1] 58 | 59 | if (top.ready && page === top.targetPage && top.callbackPath.length === path.length) { 60 | for (let i = 0; i < path.length; i++) { 61 | if (path[i] !== top.callbackPath[i]) { 62 | return undefined 63 | } 64 | } 65 | 66 | return registeredCallbacks.pop() 67 | } 68 | } 69 | 70 | return undefined 71 | } 72 | 73 | public finishWithResult(params: Record): void { 74 | if (registeredCallbacks.length > 0) { 75 | const top = registeredCallbacks[registeredCallbacks.length - 1] 76 | 77 | if (top.targetPage === this.currentPage) { 78 | top.ready = true 79 | top.result = params 80 | } 81 | } 82 | 83 | this.goBack() 84 | } 85 | 86 | private changeRoute(route: string, params?: Record) { 87 | this.currentRoute = route 88 | WorkingTree.dropAll() 89 | this.routes[route](params) 90 | } 91 | 92 | public get currentPage(): string { 93 | return this.currentRoute 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/effects/animation/RepeatAnimation.ts: -------------------------------------------------------------------------------- 1 | import { Animation, AnimationProps, AnimationType } from './Animation.js' 2 | import { coerce } from '../../../utils.js' 3 | import { TimingAnimation } from './TimingAnimation.js' 4 | import { RememberedMutableValue } from '../RememberedMutableValue.js' 5 | 6 | export interface RepeatAnimationProps extends AnimationProps { 7 | repeatCount?: number 8 | reverse?: boolean 9 | } 10 | 11 | export class RepeatAnimation extends Animation { 12 | private animation: Animation 13 | private repeatCount: number 14 | private reverse: boolean 15 | private repetitionsDone: number 16 | 17 | constructor(animation: Animation, props?: RepeatAnimationProps) { 18 | super(props) 19 | 20 | this.animation = animation 21 | this.repeatCount = props?.repeatCount ?? -1 22 | this.reverse = props?.reverse ?? false 23 | this.repetitionsDone = 0 24 | 25 | const index = Animation.runningAnimations.indexOf(animation) 26 | if (index !== -1) { 27 | Animation.runningAnimations.splice(index, 1) 28 | } 29 | } 30 | 31 | public calculateValue(timestamp: number): number { 32 | this.animation.startValue = this.startValue 33 | this.animation.rememberedValue = this.rememberedValue 34 | 35 | const value = 36 | this.reverse && this.repetitionsDone % 2 !== 0 37 | ? this.animation.calculateReversedValue(this.animation.calculateValue(timestamp)) 38 | : this.animation.calculateValue(timestamp) 39 | 40 | if (this.animation.isFinished) { 41 | this.repetitionsDone++ 42 | this.animation.onEnd(true) 43 | 44 | if (this.repeatCount > 0 && this.repetitionsDone >= this.repeatCount) { 45 | this.isFinished = true 46 | } else { 47 | this.animation.reset() 48 | } 49 | } 50 | 51 | return value 52 | } 53 | 54 | public save(): Record | undefined { 55 | const result = super.save()! 56 | 57 | result.type = AnimationType.Repeat 58 | result.repeatCount = this.repeatCount 59 | result.reverse = this.reverse 60 | result.repetitionsDone = this.repetitionsDone 61 | result.animation = this.animation.save() 62 | 63 | return result 64 | } 65 | 66 | public static restore(from: Record): RepeatAnimation | null { 67 | let restored: Animation 68 | // @ts-ignore 69 | if (from.animation.type === AnimationType.Timing) { 70 | restored = TimingAnimation.restore(from.animation as Record) 71 | } else { 72 | return null 73 | } 74 | 75 | const result = new RepeatAnimation(restored) 76 | 77 | result.startValue = from.startValue as number 78 | result.repeatCount = from.repeatCount as number 79 | result.reverse = from.reverse as boolean 80 | result.repetitionsDone = from.repetitionsDone as number 81 | 82 | return result 83 | } 84 | } 85 | 86 | export function withRepeat(animation: Animation, props?: RepeatAnimationProps) { 87 | return new RepeatAnimation(animation, props) 88 | } 89 | -------------------------------------------------------------------------------- /watch-test/page/scrollable.js: -------------------------------------------------------------------------------- 1 | import { ScrollableScreen, rememberScrollPosition } from '@zapp-framework/watch' 2 | import { 3 | SimpleScreen, 4 | Stack, 5 | StackConfig, 6 | StackAlignment, 7 | Config, 8 | BareText, 9 | TextConfig, 10 | remember, 11 | sideEffect, 12 | withTiming, 13 | Column, 14 | Row, 15 | RowConfig, 16 | Alignment, 17 | Arrangement, 18 | Easing, 19 | ColumnConfig, 20 | ArcConfig, 21 | Navigator, 22 | registerGestureEventHandler, 23 | } from '@zapp-framework/core' 24 | 25 | ScrollableScreen(Config('screen'), (params) => { 26 | const scroll = rememberScrollPosition() 27 | 28 | Stack(StackConfig('stack').alignment(StackAlignment.TopEnd).fillSize(), () => { 29 | Column( 30 | ColumnConfig('column').fillWidth().alignment(Alignment.Center).background(0xaaaaff), 31 | () => { 32 | Stack( 33 | StackConfig('s1') 34 | .width(150) 35 | .height(150) 36 | .background(0xff0000) 37 | .onPointerDown(() => { 38 | scroll.value = withTiming(400, { duration: 1000, easing: Easing.easeInOutCubic }) 39 | }) 40 | ) 41 | Stack( 42 | StackConfig('s2') 43 | .width(150) 44 | .height(150) 45 | .background(0x00ff00) 46 | .onPointerDown((e) => { 47 | console.log(e.x, e.y) 48 | Navigator.navigate('page/page3', { data: 'from green' }) 49 | }) 50 | ) 51 | Stack( 52 | StackConfig('s3') 53 | .width(150) 54 | .height(150) 55 | .background(0x0000ff) 56 | .onPointerDown((e) => { 57 | console.log(e.x, e.y) 58 | Navigator.navigate('page/page3', { data: 'from blue' }) 59 | }) 60 | ) 61 | Stack( 62 | StackConfig('s4') 63 | .width(150) 64 | .height(150) 65 | .background(0xff0000) 66 | .onPointerDown((e) => { 67 | console.log(e.x, e.y) 68 | Navigator.navigate('page/page3', { data: 'from red' }) 69 | }) 70 | ) 71 | Stack( 72 | StackConfig('s5') 73 | .width(150) 74 | .height(150) 75 | .background(0x00ff00) 76 | .onPointerDown((e) => { 77 | console.log(e.x, e.y) 78 | Navigator.navigate('page/page3', { data: 'from green' }) 79 | }) 80 | ) 81 | Stack( 82 | StackConfig('s6') 83 | .width(150) 84 | .height(150) 85 | .background(0x0000ff) 86 | .onPointerDown((e) => { 87 | console.log(e.x, e.y) 88 | Navigator.navigate('page/page3', { data: 'from blue' }) 89 | }) 90 | ) 91 | } 92 | ) 93 | 94 | Stack( 95 | StackConfig('floating') 96 | .width(30) 97 | .height(30) 98 | .cornerRadius(15) 99 | .background(0x000000) 100 | .offset(-10, 240 - hmApp.getLayerY()) 101 | ) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /@zapp-framework/web/src/ZappWeb.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PointerEventManager, 3 | Renderer, 4 | WorkingTree, 5 | ZappInterface, 6 | Platform, 7 | Animation, 8 | GlobalEventManager, 9 | GestureType, 10 | EventType, 11 | ButtonAction, 12 | ScreenShape, 13 | } from '@zapp-framework/core' 14 | import { tryUpdatingRememberedScrollPositions } from './rememberScrollPosition.js' 15 | 16 | export class ZappWeb extends ZappInterface { 17 | private running = false 18 | private crownDelta = 0 19 | private previousCrownDelta = 0 20 | private crownResetTimeout = -1 21 | private savedData: Record = {} 22 | 23 | public startLoop() { 24 | this.running = true 25 | WorkingTree.requestUpdate() 26 | requestAnimationFrame(this.update) 27 | 28 | window.addEventListener('wheel', (e) => { 29 | this.crownDelta += e.deltaY / 10 30 | 31 | clearTimeout(this.crownResetTimeout) 32 | setTimeout(() => { 33 | this.crownDelta = 0 34 | }, 100) 35 | }) 36 | 37 | window.addEventListener('keydown', (e) => { 38 | switch (e.key) { 39 | case 'Home': 40 | GlobalEventManager.dispatchButtonEvent(EventType.HomeButton, ButtonAction.Press) 41 | break 42 | case 'End': 43 | GlobalEventManager.dispatchButtonEvent(EventType.ShortcutButton, ButtonAction.Press) 44 | break 45 | } 46 | }) 47 | 48 | window.addEventListener('keyup', (e) => { 49 | switch (e.key) { 50 | case 'ArrowUp': 51 | GlobalEventManager.dispatchGestureEvent(GestureType.Up) 52 | break 53 | case 'ArrowDown': 54 | GlobalEventManager.dispatchGestureEvent(GestureType.Down) 55 | break 56 | case 'ArrowLeft': 57 | GlobalEventManager.dispatchGestureEvent(GestureType.Left) 58 | break 59 | case 'ArrowRight': 60 | GlobalEventManager.dispatchGestureEvent(GestureType.Right) 61 | break 62 | case 'Home': 63 | GlobalEventManager.dispatchButtonEvent(EventType.HomeButton, ButtonAction.Release) 64 | break 65 | case 'End': 66 | GlobalEventManager.dispatchButtonEvent(EventType.ShortcutButton, ButtonAction.Release) 67 | break 68 | } 69 | }) 70 | 71 | window.addEventListener('scroll', () => { 72 | tryUpdatingRememberedScrollPositions() 73 | }) 74 | } 75 | 76 | setValue(key: string, value: unknown): void { 77 | this.savedData[key] = value 78 | } 79 | 80 | getValue(key: string): unknown { 81 | return this.savedData[key] 82 | } 83 | 84 | stopLoop(): void { 85 | this.running = false 86 | } 87 | 88 | get platform(): Platform { 89 | return Platform.Web 90 | } 91 | 92 | get screenShape() { 93 | return ScreenShape.Square 94 | } 95 | 96 | private update = () => { 97 | if (this.crownDelta !== this.previousCrownDelta) { 98 | this.previousCrownDelta = this.crownDelta 99 | GlobalEventManager.dispatchCrownEvent(this.crownDelta) 100 | } 101 | 102 | PointerEventManager.processEvents() 103 | GlobalEventManager.tick() 104 | Animation.nextFrame(Date.now()) 105 | 106 | if (WorkingTree.hasUpdates()) { 107 | WorkingTree.performUpdate() 108 | Renderer.commit(WorkingTree.root) 109 | Renderer.render() 110 | } 111 | 112 | if (this.running) { 113 | requestAnimationFrame(this.update) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/screens/PageWrapper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Zapp, 3 | PointerEventManager, 4 | WorkingTree, 5 | Navigator, 6 | GlobalEventManager, 7 | GestureType, 8 | EventType, 9 | ButtonAction, 10 | } from '@zapp-framework/core' 11 | 12 | function mapGesture(gesture: unknown): GestureType { 13 | switch (gesture) { 14 | case hmApp.gesture.UP: 15 | return GestureType.Up 16 | case hmApp.gesture.DOWN: 17 | return GestureType.Down 18 | case hmApp.gesture.RIGHT: 19 | return GestureType.Right 20 | default: 21 | return GestureType.Left 22 | } 23 | } 24 | 25 | let restoredState: unknown = null 26 | 27 | export function PageWrapper(lifecycle: { 28 | build: (params: Record) => void 29 | initialize?: () => void 30 | destroy?: () => void 31 | restoreState?: (state: unknown) => void 32 | }) { 33 | Page({ 34 | onInit(params) { 35 | this.receivedParams = params === undefined ? {} : JSON.parse(params) 36 | 37 | Zapp.startLoop() 38 | 39 | const navigatorData = getApp()._options.globalData._navigator 40 | if (navigatorData.shouldRestore as boolean) { 41 | const state = navigatorData.savedStates.pop() 42 | navigatorData.shouldRestore = false 43 | WorkingTree.restoreState(state.treeState) 44 | 45 | restoredState = state.screenState 46 | } 47 | 48 | hmApp.registerGestureEvent(function (event: unknown) { 49 | if (PointerEventManager.hasCapturedPointers()) { 50 | return true 51 | } 52 | 53 | if (GlobalEventManager.dispatchGestureEvent(mapGesture(event))) { 54 | return true 55 | } 56 | 57 | if (event === hmApp.gesture.RIGHT) { 58 | Navigator.goBack() 59 | return true 60 | } 61 | 62 | return false 63 | }) 64 | 65 | hmApp.registerSpinEvent((_key: unknown, degree: number) => { 66 | return GlobalEventManager.dispatchCrownEvent(degree) 67 | }) 68 | 69 | hmApp.registerKeyEvent((key: unknown, sourceAction: unknown) => { 70 | let type: EventType | null = null 71 | let action: ButtonAction | null = null 72 | 73 | switch (key) { 74 | case hmApp.key.HOME: 75 | type = EventType.HomeButton 76 | break 77 | case hmApp.key.SHORTCUT: 78 | type = EventType.ShortcutButton 79 | this.text = '' 80 | break 81 | } 82 | 83 | switch (sourceAction) { 84 | case hmApp.action.RELEASE: 85 | action = ButtonAction.Release 86 | break 87 | case hmApp.action.PRESS: 88 | action = ButtonAction.Press 89 | break 90 | } 91 | 92 | if (type !== null && action !== null) { 93 | GlobalEventManager.dispatchButtonEvent(type, action) 94 | } 95 | 96 | return key === hmApp.key.HOME || key === hmApp.key.SHORTCUT 97 | }) 98 | 99 | lifecycle.initialize?.() 100 | }, 101 | build() { 102 | if (restoredState !== null) { 103 | lifecycle.restoreState?.(restoredState) 104 | restoredState = null 105 | } 106 | lifecycle.build(this.receivedParams) 107 | }, 108 | onDestroy() { 109 | lifecycle.destroy?.() 110 | Zapp.stopLoop() 111 | hmApp.unregisterGestureEvent() 112 | hmApp.unregistSpinEvent() 113 | hmApp.unregisterKeyEvent() 114 | }, 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Application, 3 | ApplicationConfig, 4 | setApplicationImplementation as __setApplicationImplementation, 5 | } from './Application.js' 6 | export { remember } from './working_tree/effects/remember.js' 7 | export { rememberObservable } from './working_tree/effects/rememberObservable.js' 8 | export { rememberLauncherForResult } from './working_tree/effects/rememberLauncherForResult.js' 9 | export { registerCrownEventHandler } from './working_tree/effects/registerCrownEventHandler.js' 10 | export { registerHomeButtonEventHandler } from './working_tree/effects/registerHomeButtonEventHandler.js' 11 | export { registerShortcutButtonEventHandler } from './working_tree/effects/registerShortcutButtonEventHandler.js' 12 | export { 13 | registerGestureEventHandler, 14 | GestureType, 15 | } from './working_tree/effects/registerGestureEventHandler.js' 16 | export { RememberedMutableValue } from './working_tree/effects/RememberedMutableValue.js' 17 | export { sideEffect } from './working_tree/effects/sideEffect.js' 18 | export { Arc } from './working_tree/views/Arc.js' 19 | export { Custom } from './working_tree/views/Custom.js' 20 | export { Stack } from './working_tree/views/Stack.js' 21 | export { Column } from './working_tree/views/Column.js' 22 | export { Row } from './working_tree/views/Row.js' 23 | export { Image } from './working_tree/views/Image.js' 24 | export { 25 | ScreenBody, 26 | SimpleScreen, 27 | setSimpleScreenImplementation as __setSimpleScreenImplementation, 28 | } from './working_tree/views/Screen.js' 29 | export { BareText } from './working_tree/views/BareText.js' 30 | export { WorkingTree } from './working_tree/WorkingTree.js' 31 | export { Config, BaseConfigBuilder } from './working_tree/props/BaseConfig.js' 32 | export { ConfigBuilder } from './working_tree/props/Config.js' 33 | export { 34 | ConfigType, 35 | PointerData, 36 | PointerEventType, 37 | Alignment, 38 | Arrangement, 39 | StackAlignment, 40 | } from './working_tree/props/types.js' 41 | export { ArcConfig } from './working_tree/props/ArcConfig.js' 42 | export { TextConfig } from './working_tree/props/TextConfig.js' 43 | export { StackConfig } from './working_tree/props/StackConfig.js' 44 | export { ColumnConfig } from './working_tree/props/ColumnConfig.js' 45 | export { ImageConfig } from './working_tree/props/ImageConfig.js' 46 | export { RowConfig } from './working_tree/props/RowConfig.js' 47 | export { Animation } from './working_tree/effects/animation/Animation.js' 48 | export { Easing } from './working_tree/effects/animation/Easing.js' 49 | export { withTiming } from './working_tree/effects/animation/TimingAnimation.js' 50 | export { withRepeat } from './working_tree/effects/animation/RepeatAnimation.js' 51 | export { Renderer } from './renderer/Renderer.js' 52 | export { RenderNode } from './renderer/RenderedTree.js' 53 | export { ViewManagerInterface, setViewManager as __setViewManager } from './renderer/ViewManager.js' 54 | export { PointerEventManager } from './renderer/PointerEventManager.js' 55 | export { GlobalEventManager } from './renderer/GlobalEventManager.js' 56 | export { NodeType } from './NodeType.js' 57 | export { 58 | ZappInterface, 59 | Platform, 60 | ScreenShape, 61 | Zapp, 62 | setZappInterface as __setZappInterface, 63 | } from './ZappInterface.js' 64 | export { 65 | NavigatorInterface, 66 | Navigator, 67 | RegisteredCallback, 68 | setNavigator as __setNavigator, 69 | } from './Navigator.js' 70 | export { SavedTreeState } from './working_tree/SavedTreeState.js' 71 | export { Color } from './Color.js' 72 | export { EventType, ButtonAction } from './working_tree/EventNode.js' 73 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/Switch.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConfigType, 3 | ConfigBuilder, 4 | Stack, 5 | StackConfig, 6 | remember, 7 | sideEffect, 8 | withTiming, 9 | Easing, 10 | Color, 11 | } from '@zapp-framework/core' 12 | import { Theme } from './Theme.js' 13 | 14 | interface SwitchConfigType extends ConfigType { 15 | isChecked: boolean 16 | onChange?: (isChecked: boolean) => void 17 | } 18 | 19 | export class SwitchConfigBuilder extends ConfigBuilder { 20 | protected config: SwitchConfigType 21 | 22 | constructor(id: string) { 23 | super(id) 24 | this.config.isChecked = false 25 | } 26 | 27 | isChecked(isChecked: boolean) { 28 | this.config.isChecked = isChecked 29 | return this 30 | } 31 | 32 | onChange(handler: (isChecked: boolean) => void) { 33 | this.config.onChange = handler 34 | return this 35 | } 36 | 37 | build(): SwitchConfigType { 38 | return this.config 39 | } 40 | } 41 | 42 | export function SwitchConfig(id: string): SwitchConfigBuilder { 43 | return new SwitchConfigBuilder(id) 44 | } 45 | 46 | export function Switch(config: SwitchConfigBuilder) { 47 | const rawConfig = config.build() 48 | 49 | const [r, g, b] = Color.toRGB(rawConfig.isChecked ? Theme.primaryContainer : Theme.surfaceVariant) 50 | 51 | Stack(StackConfig(`${rawConfig.id}#wrapper`), () => { 52 | const pressed = remember(false) 53 | const pressPosition = remember({ x: 0, y: 0 }) 54 | const foregroundOffset = remember(rawConfig.isChecked ? px(60) : 0) 55 | const timer = remember(0) 56 | 57 | const backgroundR = remember(r) 58 | const backgroundG = remember(g) 59 | const backgroundB = remember(b) 60 | 61 | sideEffect(() => { 62 | const animationConfig = { duration: 200, easing: Easing.easeOutQuad } 63 | const [r, g, b] = Color.toRGB( 64 | rawConfig.isChecked ? Theme.primaryContainer : Theme.surfaceVariant 65 | ) 66 | 67 | foregroundOffset.value = withTiming(rawConfig.isChecked ? px(60) : 0, animationConfig) 68 | backgroundR.value = withTiming(r, animationConfig) 69 | backgroundG.value = withTiming(g, animationConfig) 70 | backgroundB.value = withTiming(b, animationConfig) 71 | }, rawConfig.isChecked) 72 | 73 | Stack( 74 | StackConfig(`${rawConfig.id}#background`) 75 | .width(px(120)) 76 | .height(px(60)) 77 | .cornerRadius(px(30)) 78 | .background(Color.rgb(backgroundR.value, backgroundG.value, backgroundB.value)) 79 | .padding(px(10)) 80 | .onPointerDown((e) => { 81 | pressed.value = true 82 | pressPosition.value = { x: e.x, y: e.y } 83 | 84 | timer.value = withTiming(0, { 85 | duration: 500, 86 | onEnd: (finished) => { 87 | if (finished) { 88 | pressed.value = false 89 | } 90 | }, 91 | }) 92 | }) 93 | .onPointerUp(() => { 94 | if (pressed.value) { 95 | pressed.value = false 96 | rawConfig.onChange?.(!rawConfig.isChecked) 97 | } 98 | }) 99 | .onPointerMove((e) => { 100 | if ( 101 | Math.sqrt( 102 | (e.x - pressPosition.value.x) * (e.x - pressPosition.value.x) + 103 | (e.y - pressPosition.value.y) * (e.y - pressPosition.value.y) 104 | ) > px(24) 105 | ) { 106 | pressed.value = false 107 | } 108 | }) 109 | .onPointerLeave(() => { 110 | pressed.value = false 111 | }), 112 | () => { 113 | Stack( 114 | StackConfig(`${rawConfig.id}#foreground`) 115 | .width(px(40)) 116 | .height(px(40)) 117 | .cornerRadius(px(20)) 118 | .background(Theme.onPrimaryContainer) 119 | .offset(foregroundOffset.value, 0) 120 | ) 121 | } 122 | ) 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/Theme.ts: -------------------------------------------------------------------------------- 1 | import { Zapp } from '@zapp-framework/core' 2 | 3 | const SAVED_THEME_KEY = 'zapp#theme' 4 | 5 | export interface ThemeInterface { 6 | primary: number 7 | onPrimary: number 8 | primaryContainer: number 9 | onPrimaryContainer: number 10 | 11 | secondary: number 12 | onSecondary: number 13 | secondaryContainer: number 14 | onSecondaryContainer: number 15 | 16 | tertiary: number 17 | onTertiary: number 18 | tertiaryContainer: number 19 | onTertiaryContainer: number 20 | 21 | error: number 22 | onError: number 23 | errorContainer: number 24 | onErrorContainer: number 25 | 26 | background: number 27 | onBackground: number 28 | surface: number 29 | onSurface: number 30 | 31 | outline: number 32 | surfaceVariant: number 33 | onSurfaceVariant: number 34 | } 35 | 36 | // generated using https://m3.material.io/theme-builder#/custom with #db367e as a primary color 37 | let currentTheme: ThemeInterface = (Zapp.getValue(SAVED_THEME_KEY) as ThemeInterface) ?? { 38 | primary: 0xffb1c8, 39 | onPrimary: 0x650033, 40 | primaryContainer: 0x8e004a, 41 | onPrimaryContainer: 0xffd9e2, 42 | 43 | secondary: 0xe3bdc6, 44 | onSecondary: 0x422931, 45 | secondaryContainer: 0x5a3f47, 46 | onSecondaryContainer: 0xffd9e2, 47 | 48 | tertiary: 0xefbd94, 49 | onTertiary: 0x48290b, 50 | tertiaryContainer: 0x613f20, 51 | onTertiaryContainer: 0xffdcc1, 52 | 53 | error: 0xffb4ab, 54 | onError: 0x690005, 55 | errorContainer: 0x93000a, 56 | onErrorContainer: 0xffdad6, 57 | 58 | background: 0x000000, 59 | onBackground: 0xebe0e1, 60 | surface: 0x201a1b, 61 | onSurface: 0xebe0e1, 62 | 63 | outline: 0x9e8c90, 64 | surfaceVariant: 0x514347, 65 | onSurfaceVariant: 0xd5c2c6, 66 | } 67 | 68 | export function setTheme(theme?: ThemeInterface) { 69 | if (theme !== undefined) { 70 | currentTheme = theme 71 | } 72 | 73 | Zapp.setValue(SAVED_THEME_KEY, theme) 74 | } 75 | 76 | export abstract class Theme { 77 | static get primary() { 78 | return currentTheme.primary 79 | } 80 | static get onPrimary() { 81 | return currentTheme.onPrimary 82 | } 83 | static get primaryContainer() { 84 | return currentTheme.primaryContainer 85 | } 86 | static get onPrimaryContainer() { 87 | return currentTheme.onPrimaryContainer 88 | } 89 | 90 | static get secondary() { 91 | return currentTheme.secondary 92 | } 93 | static get onSecondary() { 94 | return currentTheme.onSecondary 95 | } 96 | static get secondaryContainer() { 97 | return currentTheme.secondaryContainer 98 | } 99 | static get onSecondaryContainer() { 100 | return currentTheme.onSecondaryContainer 101 | } 102 | 103 | static get tertiary() { 104 | return currentTheme.tertiary 105 | } 106 | static get onTertiary() { 107 | return currentTheme.onTertiary 108 | } 109 | static get tertiaryContainer() { 110 | return currentTheme.tertiaryContainer 111 | } 112 | static get onTertiaryContainer() { 113 | return currentTheme.onTertiaryContainer 114 | } 115 | 116 | static get error() { 117 | return currentTheme.error 118 | } 119 | static get onError() { 120 | return currentTheme.onError 121 | } 122 | static get errorContainer() { 123 | return currentTheme.errorContainer 124 | } 125 | static get onErrorContainer() { 126 | return currentTheme.onErrorContainer 127 | } 128 | 129 | static get background() { 130 | return currentTheme.background 131 | } 132 | static get onBackground() { 133 | return currentTheme.onBackground 134 | } 135 | static get surface() { 136 | return currentTheme.surface 137 | } 138 | static get onSurface() { 139 | return currentTheme.onSurface 140 | } 141 | 142 | static get outline() { 143 | return currentTheme.outline 144 | } 145 | static get surfaceVariant() { 146 | return currentTheme.surfaceVariant 147 | } 148 | static get onSurfaceVariant() { 149 | return currentTheme.onSurfaceVariant 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /watch-test/page/pager.js: -------------------------------------------------------------------------------- 1 | import { 2 | ScreenPager, 3 | PagerEntry, 4 | rememberCurrentPage, 5 | ScreenPagerConfig, 6 | rememberSavable, 7 | } from '@zapp-framework/watch' 8 | import { 9 | SimpleScreen, 10 | Stack, 11 | StackConfig, 12 | StackAlignment, 13 | Config, 14 | BareText, 15 | TextConfig, 16 | remember, 17 | sideEffect, 18 | withTiming, 19 | Column, 20 | Row, 21 | RowConfig, 22 | Alignment, 23 | Arrangement, 24 | Easing, 25 | ColumnConfig, 26 | ArcConfig, 27 | Navigator, 28 | registerGestureEventHandler, 29 | } from '@zapp-framework/core' 30 | import { 31 | Button, 32 | ButtonConfig, 33 | PageIndicator, 34 | PageIndicatorConfig, 35 | Text, 36 | Switch, 37 | SwitchConfig, 38 | } from '@zapp-framework/ui' 39 | 40 | function renderPageIndicator(current) { 41 | PageIndicator(PageIndicatorConfig('indicator').numberOfPages(5).currentPage(current)) 42 | } 43 | 44 | ScreenPager(ScreenPagerConfig('screen', 5).startingPage(2), () => { 45 | PagerEntry(Config('page1'), () => { 46 | Column( 47 | ColumnConfig('page1Column') 48 | .fillSize() 49 | .alignment(Alignment.Center) 50 | .arrangement(Arrangement.Center), 51 | () => { 52 | const isChecked = remember(true) 53 | Switch( 54 | SwitchConfig('switch') 55 | .isChecked(isChecked.value) 56 | .onChange((v) => { 57 | isChecked.value = v 58 | }) 59 | ) 60 | Text(TextConfig('page1Text').textSize(80), '1') 61 | } 62 | ) 63 | 64 | renderPageIndicator(0) 65 | }) 66 | 67 | PagerEntry(Config('page2'), () => { 68 | Column( 69 | ColumnConfig('page2Column') 70 | .fillSize() 71 | .alignment(Alignment.Center) 72 | .arrangement(Arrangement.Center), 73 | () => { 74 | const isChecked = remember(true) 75 | Switch( 76 | SwitchConfig('switch2') 77 | .isChecked(isChecked.value) 78 | .onChange((v) => { 79 | isChecked.value = v 80 | }) 81 | ) 82 | Text(TextConfig('page2Text').textSize(80), '2') 83 | } 84 | ) 85 | 86 | renderPageIndicator(1) 87 | }) 88 | 89 | PagerEntry(Config('page3'), () => { 90 | Column( 91 | ColumnConfig('page3Column') 92 | .fillSize() 93 | .alignment(Alignment.Center) 94 | .arrangement(Arrangement.Center), 95 | () => { 96 | Button(ButtonConfig('page3Button'), () => { 97 | Text(TextConfig('page3ButtonText'), 'Do stuff') 98 | }) 99 | Text(TextConfig('page3Text').textSize(80), '3') 100 | } 101 | ) 102 | 103 | renderPageIndicator(2) 104 | }) 105 | 106 | PagerEntry(Config('page4'), () => { 107 | Column( 108 | ColumnConfig('page4Column') 109 | .fillSize() 110 | .alignment(Alignment.Center) 111 | .arrangement(Arrangement.Center), 112 | () => { 113 | const saved = rememberSavable('number', 1) 114 | Button( 115 | ButtonConfig('page4Button').onPress(() => { 116 | Navigator.navigate('page/picker') 117 | }), 118 | () => { 119 | Text(TextConfig('page4ButtonText'), 'Do stuff') 120 | } 121 | ) 122 | Text(TextConfig('page4Text').textSize(80), '4') 123 | Text(TextConfig('page4Text').textSize(20), `Picked number: ${saved.value}`) 124 | } 125 | ) 126 | 127 | renderPageIndicator(3) 128 | }) 129 | 130 | PagerEntry(Config('page5'), () => { 131 | Column( 132 | ColumnConfig('page5Column') 133 | .fillSize() 134 | .alignment(Alignment.Center) 135 | .arrangement(Arrangement.Center), 136 | () => { 137 | Button( 138 | ButtonConfig('page5Button').onPress(() => { 139 | Navigator.navigate('page/picker') 140 | }), 141 | () => { 142 | Text(TextConfig('page5ButtonText'), 'Do stuff') 143 | } 144 | ) 145 | Text(TextConfig('page5Text').textSize(80), '5') 146 | } 147 | ) 148 | 149 | renderPageIndicator(4) 150 | }) 151 | }) 152 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/CheckBox.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConfigType, 3 | ConfigBuilder, 4 | Alignment, 5 | Color, 6 | Easing, 7 | remember, 8 | Row, 9 | RowConfig, 10 | sideEffect, 11 | Stack, 12 | StackAlignment, 13 | StackConfig, 14 | withTiming, 15 | } from '@zapp-framework/core' 16 | import { Theme } from './Theme.js' 17 | 18 | interface CheckBoxConfigType extends ConfigType { 19 | checked: boolean 20 | onChange?: (checked: boolean) => void 21 | } 22 | 23 | export class CheckBoxConfigBuilder extends ConfigBuilder { 24 | protected config: CheckBoxConfigType 25 | 26 | constructor(id: string) { 27 | super(id) 28 | this.config.checked = false 29 | } 30 | 31 | onChange(handler: (checked: boolean) => void) { 32 | this.config.onChange = handler 33 | return this 34 | } 35 | 36 | checked(checked: boolean) { 37 | this.config.checked = checked 38 | return this 39 | } 40 | 41 | build(): CheckBoxConfigType { 42 | return this.config 43 | } 44 | } 45 | 46 | export function CheckBoxConfig(id: string): CheckBoxConfigBuilder { 47 | return new CheckBoxConfigBuilder(id) 48 | } 49 | 50 | export function CheckBox(config: CheckBoxConfigBuilder, body?: () => void) { 51 | const rawConfig = config.build() 52 | 53 | const [r, g, b] = Color.toRGB(rawConfig.checked ? Theme.primaryContainer : Theme.surfaceVariant) 54 | 55 | Stack(StackConfig(`${rawConfig.id}#wrapper`), () => { 56 | const pressed = remember(false) 57 | const pressPosition = remember({ x: 0, y: 0 }) 58 | const timer = remember(0) 59 | 60 | Row( 61 | RowConfig(`${rawConfig.id}#row`) 62 | .alignment(Alignment.Center) 63 | .padding(px(8)) 64 | .onPointerDown((e) => { 65 | pressed.value = true 66 | pressPosition.value = { x: e.x, y: e.y } 67 | 68 | timer.value = withTiming(0, { 69 | duration: 500, 70 | onEnd: (finished) => { 71 | if (finished) { 72 | pressed.value = false 73 | } 74 | }, 75 | }) 76 | }) 77 | .onPointerUp(() => { 78 | if (pressed.value) { 79 | pressed.value = false 80 | rawConfig.onChange?.(!rawConfig.checked) 81 | } 82 | }) 83 | .onPointerMove((e) => { 84 | if ( 85 | Math.sqrt( 86 | (e.x - pressPosition.value.x) * (e.x - pressPosition.value.x) + 87 | (e.y - pressPosition.value.y) * (e.y - pressPosition.value.y) 88 | ) > px(24) 89 | ) { 90 | pressed.value = false 91 | } 92 | }) 93 | .onPointerLeave(() => { 94 | pressed.value = false 95 | }), 96 | () => { 97 | const foregroundSize = remember(rawConfig.checked ? px(30) : 0) 98 | const backgroundR = remember(r) 99 | const backgroundG = remember(g) 100 | const backgroundB = remember(b) 101 | 102 | sideEffect(() => { 103 | const animationConfig = { duration: 200, easing: Easing.easeOutQuad } 104 | const [r, g, b] = Color.toRGB( 105 | rawConfig.checked ? Theme.primaryContainer : Theme.surfaceVariant 106 | ) 107 | 108 | foregroundSize.value = withTiming(rawConfig.checked ? px(30) : 0, animationConfig) 109 | backgroundR.value = withTiming(r, animationConfig) 110 | backgroundG.value = withTiming(g, animationConfig) 111 | backgroundB.value = withTiming(b, animationConfig) 112 | }, rawConfig.checked) 113 | 114 | Stack( 115 | StackConfig(`${rawConfig.id}#background`) 116 | .alignment(StackAlignment.Center) 117 | .width(px(60)) 118 | .height(px(60)) 119 | .cornerRadius(px(15)) 120 | .background(Color.rgb(backgroundR.value, backgroundG.value, backgroundB.value)) 121 | .padding(px(15)), 122 | () => { 123 | Stack( 124 | StackConfig(`${rawConfig.id}#foreground`) 125 | .width(foregroundSize.value) 126 | .height(foregroundSize.value) 127 | .cornerRadius(foregroundSize.value / 4) 128 | .background(Theme.onPrimaryContainer) 129 | ) 130 | } 131 | ) 132 | Stack(StackConfig(`${rawConfig.id}#spacer`).width(px(16))) 133 | body?.() 134 | } 135 | ) 136 | }) 137 | } 138 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/PageIndicator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arrangement, 3 | Column, 4 | ColumnConfig, 5 | ConfigBuilder, 6 | ConfigType, 7 | remember, 8 | Row, 9 | RowConfig, 10 | ScreenShape, 11 | Stack, 12 | StackAlignment, 13 | StackConfig, 14 | Zapp, 15 | } from '@zapp-framework/core' 16 | import { Theme } from './Theme.js' 17 | 18 | const DOT_SIZE = px(16) 19 | const DOT_SPACING = px(2) 20 | const SPACE_FOR_DOT = DOT_SIZE + DOT_SPACING * 2 21 | 22 | interface PageIndicatorConfigType extends ConfigType { 23 | numberOfPages: number 24 | currentPage: number 25 | curved: boolean 26 | curveRadius: number 27 | horizontal: boolean 28 | } 29 | 30 | export class PageIndicatorConfigBuilder extends ConfigBuilder { 31 | protected config: PageIndicatorConfigType 32 | 33 | constructor(id: string) { 34 | super(id) 35 | this.config.numberOfPages = 1 36 | this.config.currentPage = 0 37 | this.config.curveRadius = Zapp.screenWidth / 2 38 | this.config.curved = Zapp.screenShape === ScreenShape.Round 39 | this.config.horizontal = true 40 | } 41 | 42 | numberOfPages(numberOfPages: number) { 43 | this.config.numberOfPages = numberOfPages 44 | return this 45 | } 46 | 47 | currentPage(currentPage: number) { 48 | this.config.currentPage = currentPage 49 | return this 50 | } 51 | 52 | curveRadius(curveRadius: number) { 53 | this.config.curveRadius = curveRadius 54 | return this 55 | } 56 | 57 | curved(curved: boolean) { 58 | this.config.curved = curved 59 | return this 60 | } 61 | 62 | horizontal(horizontal: boolean) { 63 | this.config.horizontal = horizontal 64 | return this 65 | } 66 | 67 | build(): PageIndicatorConfigType { 68 | return this.config 69 | } 70 | } 71 | 72 | export function PageIndicatorConfig(id: string): PageIndicatorConfigBuilder { 73 | return new PageIndicatorConfigBuilder(id) 74 | } 75 | 76 | function calculateDotOffset(index: number, config: PageIndicatorConfigType, radius?: number) { 77 | let oX = 0 78 | let oY = 0 79 | 80 | if (config.curved) { 81 | let distanceFromCenter = index - Math.floor(config.numberOfPages / 2) 82 | if (config.numberOfPages % 2 === 0) { 83 | distanceFromCenter += 0.5 84 | } 85 | 86 | const r = (radius ?? config.curveRadius) - DOT_SIZE / 2 87 | const degPerDot = (180 * SPACE_FOR_DOT) / (Math.PI * r) // 360 * size / (2 * pi * r) 88 | const angularPosition = degPerDot * distanceFromCenter * 0.0174532925 89 | const relativePosition = distanceFromCenter * SPACE_FOR_DOT 90 | 91 | if (config.horizontal) { 92 | oY -= r * (1 - Math.cos(angularPosition)) 93 | oX -= relativePosition - Math.sin(angularPosition) * r 94 | } else { 95 | oX -= r * (1 - Math.cos(angularPosition)) 96 | oY -= relativePosition - Math.sin(angularPosition) * r 97 | } 98 | } 99 | 100 | return { x: oX, y: oY } 101 | } 102 | 103 | function renderDot(index: number, rawConfig: PageIndicatorConfigType) { 104 | const { x: offsetX, y: offsetY } = calculateDotOffset(index, rawConfig) 105 | 106 | Stack( 107 | StackConfig(`${rawConfig.id}#dot#${index}`) 108 | .background(index === rawConfig.currentPage ? Theme.primary : Theme.primaryContainer) 109 | .width(DOT_SIZE) 110 | .height(DOT_SIZE) 111 | .cornerRadius(DOT_SIZE / 2) 112 | .offset(offsetX, offsetY) 113 | ) 114 | } 115 | 116 | function renderDots(rawConfig: PageIndicatorConfigType) { 117 | for (let i = 0; i < rawConfig.numberOfPages; i++) { 118 | renderDot(i, rawConfig) 119 | } 120 | } 121 | 122 | export function PageIndicator(config: PageIndicatorConfigBuilder) { 123 | const rawConfig = config.build() 124 | 125 | Stack( 126 | StackConfig(`${rawConfig.id}#wrapper`) 127 | .fillSize() 128 | .positionAbsolutely(true) 129 | .alignment(rawConfig.horizontal ? StackAlignment.BottomCenter : StackAlignment.CenterEnd), 130 | () => { 131 | if (rawConfig.horizontal) { 132 | Row( 133 | RowConfig(`${rawConfig.id}#dotsContainer`) 134 | .width(rawConfig.numberOfPages * SPACE_FOR_DOT) 135 | .arrangement(Arrangement.SpaceAround), 136 | () => { 137 | renderDots(rawConfig) 138 | } 139 | ) 140 | } else { 141 | Column( 142 | ColumnConfig(`${rawConfig.id}#dotsContainer`) 143 | .height(rawConfig.numberOfPages * SPACE_FOR_DOT) 144 | .arrangement(Arrangement.SpaceAround), 145 | () => { 146 | renderDots(rawConfig) 147 | } 148 | ) 149 | } 150 | } 151 | ) 152 | } 153 | -------------------------------------------------------------------------------- /@zapp-framework/watch/src/screens/ScreenPager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Config, 3 | ConfigBuilder, 4 | ConfigType, 5 | Stack, 6 | RememberedMutableValue, 7 | rememberObservable, 8 | ScreenBody, 9 | sideEffect, 10 | PointerEventManager, 11 | BaseConfigBuilder, 12 | } from '@zapp-framework/core' 13 | import { PageWrapper } from './PageWrapper.js' 14 | import { viewManagerInstance } from './../WatchViewManager.js' 15 | import { Direction } from './../types.js' 16 | import { navigatorInstance } from '../Navigator.js' 17 | 18 | const { width: DEVICE_WIDTH, height: DEVICE_HEIGHT } = hmSetting.getDeviceInfo() 19 | 20 | let rememberedValues: RememberedMutableValue[] = [] 21 | let nextPageNumber = 0 22 | let currentDirection: Direction 23 | let previousPage = 0 24 | 25 | interface ScreenPagerConfigType extends ConfigType { 26 | direction: Direction 27 | startingPage: number 28 | numberOfPages: number 29 | } 30 | 31 | export class ScreenPagerConfigBuilder extends ConfigBuilder { 32 | protected config: ScreenPagerConfigType 33 | 34 | constructor(id: string, numberOfPages: number) { 35 | super(id) 36 | this.config.direction = Direction.Horizontal 37 | this.config.startingPage = 0 38 | this.config.numberOfPages = numberOfPages 39 | } 40 | 41 | startingPage(page: number) { 42 | this.config.startingPage = page 43 | return this 44 | } 45 | 46 | direction(direction: Direction) { 47 | this.config.direction = direction 48 | return this 49 | } 50 | 51 | build(): ScreenPagerConfigType { 52 | return this.config 53 | } 54 | } 55 | 56 | export function ScreenPagerConfig(id: string, numberOfPages: number) { 57 | return new ScreenPagerConfigBuilder(id, numberOfPages) 58 | } 59 | 60 | export function ScreenPager( 61 | configBuilder: ScreenPagerConfigBuilder, 62 | body: (params: Record) => void 63 | ) { 64 | const config = configBuilder.build() 65 | 66 | PageWrapper({ 67 | build: (params) => { 68 | Stack(Config('#pagerOpenStartingPage'), () => { 69 | sideEffect((restoring) => { 70 | if (!restoring) { 71 | hmUI.scrollToPage(config.startingPage, false) 72 | } 73 | }) 74 | }) 75 | 76 | nextPageNumber = 0 77 | currentDirection = config.direction 78 | 79 | body(params) 80 | }, 81 | initialize: () => { 82 | hmUI.setScrollView( 83 | true, 84 | config.direction === Direction.Horizontal ? DEVICE_WIDTH : DEVICE_HEIGHT, 85 | config.numberOfPages, 86 | config.direction === Direction.Vertical 87 | ) 88 | 89 | viewManagerInstance.setPageScrolling(config.direction) 90 | }, 91 | destroy: () => { 92 | // need to scroll to the first page, otherwise navigation may break and blank screen will be shown 93 | hmUI.scrollToPage(0, false) 94 | rememberedValues = [] 95 | }, 96 | restoreState: (page: number) => { 97 | hmUI.scrollToPage(page, false) 98 | }, 99 | }) 100 | } 101 | 102 | export function PagerEntry(configBuilder: BaseConfigBuilder, body: () => void) { 103 | ScreenBody( 104 | configBuilder.offset( 105 | currentDirection === Direction.Horizontal ? DEVICE_WIDTH * nextPageNumber : 0, 106 | currentDirection === Direction.Vertical ? DEVICE_HEIGHT * nextPageNumber : 0 107 | ), 108 | () => { 109 | body() 110 | } 111 | ) 112 | nextPageNumber++ 113 | } 114 | 115 | export function tryUpdatingRememberedPagePositions() { 116 | const currentPage = hmUI.getScrollCurrentPage() - 1 117 | if (previousPage !== currentPage) { 118 | navigatorInstance.saveScreenState(currentPage) 119 | PointerEventManager.cancelPointers() 120 | } 121 | 122 | let needsClear = false 123 | for (const val of rememberedValues) { 124 | val.value = currentPage 125 | 126 | // @ts-ignore that's private in the core package 127 | needsClear = needsClear || val.context.isDropped 128 | } 129 | 130 | if (needsClear) { 131 | // @ts-ignore that's private in the core package 132 | rememberedValues = rememberedValues.filter((v) => !v.context.isDropped) 133 | } 134 | 135 | previousPage = currentPage 136 | } 137 | 138 | export function rememberCurrentPage(): RememberedMutableValue { 139 | // for whatever reason, getScrollCurrentPage starts counting from 1 but scrollToPage starts from 0 140 | const value = rememberObservable(hmUI.getScrollCurrentPage() - 1, (prev, current) => { 141 | hmUI.scrollToPage(current, true) 142 | }) 143 | 144 | if (rememberedValues.indexOf(value) === -1) { 145 | rememberedValues.push(value) 146 | } 147 | return value 148 | } 149 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/RadioGroup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Alignment, 3 | Color, 4 | ConfigBuilder, 5 | ConfigType, 6 | Easing, 7 | remember, 8 | Row, 9 | RowConfig, 10 | sideEffect, 11 | Stack, 12 | StackAlignment, 13 | StackConfig, 14 | withTiming, 15 | } from '@zapp-framework/core' 16 | import { Theme } from './Theme.js' 17 | 18 | interface RadioGroupConfigType extends ConfigType { 19 | selected: number 20 | onChange?: (selected: number) => void 21 | } 22 | 23 | export class RadioGroupConfigBuilder extends ConfigBuilder { 24 | protected config: RadioGroupConfigType 25 | 26 | constructor(id: string) { 27 | super(id) 28 | this.config.selected = 0 29 | } 30 | 31 | onChange(handler: (selected: number) => void) { 32 | this.config.onChange = handler 33 | return this 34 | } 35 | 36 | selected(selected: number) { 37 | this.config.selected = selected 38 | return this 39 | } 40 | 41 | build(): RadioGroupConfigType { 42 | return this.config 43 | } 44 | } 45 | 46 | export function RadioGroupConfig(id: string): RadioGroupConfigBuilder { 47 | return new RadioGroupConfigBuilder(id) 48 | } 49 | 50 | let nextButtonIndex = 0 51 | let currentConfig: RadioGroupConfigType 52 | 53 | export function RadioGroup(config: RadioGroupConfigBuilder, body: () => void) { 54 | const rawConfig = config.build() 55 | 56 | nextButtonIndex = 0 57 | currentConfig = rawConfig 58 | 59 | body() 60 | } 61 | 62 | export function RadioButton(config: ConfigBuilder, body?: () => void) { 63 | const rawConfig = config.build() 64 | const index = nextButtonIndex++ 65 | const selected = index === currentConfig.selected 66 | 67 | const [r, g, b] = Color.toRGB(selected ? Theme.primaryContainer : Theme.surfaceVariant) 68 | 69 | Stack(StackConfig(`${rawConfig.id}#wrapper`), () => { 70 | const pressed = remember(false) 71 | const pressPosition = remember({ x: 0, y: 0 }) 72 | const timer = remember(0) 73 | 74 | Row( 75 | RowConfig(`${rawConfig.id}#row`) 76 | .alignment(Alignment.Center) 77 | .padding(px(8)) 78 | .onPointerDown((e) => { 79 | pressed.value = true 80 | pressPosition.value = { x: e.x, y: e.y } 81 | 82 | timer.value = withTiming(0, { 83 | duration: 500, 84 | onEnd: (finished) => { 85 | if (finished) { 86 | pressed.value = false 87 | } 88 | }, 89 | }) 90 | }) 91 | .onPointerUp(() => { 92 | if (pressed.value) { 93 | pressed.value = false 94 | currentConfig.onChange?.(index) 95 | } 96 | }) 97 | .onPointerMove((e) => { 98 | if ( 99 | Math.sqrt( 100 | (e.x - pressPosition.value.x) * (e.x - pressPosition.value.x) + 101 | (e.y - pressPosition.value.y) * (e.y - pressPosition.value.y) 102 | ) > px(24) 103 | ) { 104 | pressed.value = false 105 | } 106 | }) 107 | .onPointerLeave(() => { 108 | pressed.value = false 109 | }), 110 | () => { 111 | const foregroundSize = remember(selected ? px(30) : 0) 112 | const backgroundR = remember(r) 113 | const backgroundG = remember(g) 114 | const backgroundB = remember(b) 115 | 116 | sideEffect(() => { 117 | const animationConfig = { duration: 200, easing: Easing.easeOutQuad } 118 | const [r, g, b] = Color.toRGB(selected ? Theme.primaryContainer : Theme.surfaceVariant) 119 | 120 | foregroundSize.value = withTiming(selected ? px(30) : 0, animationConfig) 121 | backgroundR.value = withTiming(r, animationConfig) 122 | backgroundG.value = withTiming(g, animationConfig) 123 | backgroundB.value = withTiming(b, animationConfig) 124 | }, selected) 125 | 126 | Stack( 127 | StackConfig(`${rawConfig.id}#background`) 128 | .alignment(StackAlignment.Center) 129 | .width(px(60)) 130 | .height(px(60)) 131 | .cornerRadius(px(30)) 132 | .background(Color.rgb(backgroundR.value, backgroundG.value, backgroundB.value)) 133 | .padding(px(15)), 134 | () => { 135 | Stack( 136 | StackConfig(`${rawConfig.id}#foreground`) 137 | .width(foregroundSize.value) 138 | .height(foregroundSize.value) 139 | .cornerRadius(foregroundSize.value / 2) 140 | .background(Theme.onPrimaryContainer) 141 | ) 142 | } 143 | ) 144 | Stack(StackConfig(`${rawConfig.id}#spacer`).width(px(16))) 145 | body?.() 146 | } 147 | ) 148 | }) 149 | } 150 | -------------------------------------------------------------------------------- /assets/zapp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 25 | 29 | 33 | 34 | 44 | 45 | 64 | 66 | 67 | 69 | image/svg+xml 70 | 72 | 73 | 74 | 75 | 76 | 81 | 87 | 92 | 97 | 98 | Zapp 112 | 113 | 114 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/renderer/GlobalEventManager.ts: -------------------------------------------------------------------------------- 1 | import { Navigator } from '../Navigator.js' 2 | import { GestureType } from '../working_tree/effects/registerGestureEventHandler.js' 3 | import { ButtonAction, EventType } from '../working_tree/EventNode.js' 4 | import { PointerEventType } from '../working_tree/props/types.js' 5 | import { PointerEventManager } from './PointerEventManager.js' 6 | import { RenderedTree } from './RenderedTree.js' 7 | import { ViewManager } from './ViewManager.js' 8 | 9 | export interface EventHandler { 10 | type: EventType 11 | buttonAction?: ButtonAction 12 | handler: (...args: any[]) => boolean 13 | } 14 | 15 | class ButtonEventManager { 16 | private isPressed = false 17 | private type: EventType 18 | private pressTimestamp: number 19 | private longPressFired: boolean 20 | 21 | constructor(type: EventType) { 22 | this.type = type 23 | } 24 | 25 | public tick() { 26 | if (this.isPressed && !this.longPressFired && Date.now() - this.pressTimestamp > 500) { 27 | this.longPressFired = this.triggerEvent(ButtonAction.LongPress) 28 | } 29 | } 30 | 31 | public dispatchEvent(action: ButtonAction): boolean { 32 | if (action === ButtonAction.Press) { 33 | if (!this.isPressed) { 34 | this.pressTimestamp = Date.now() 35 | this.isPressed = true 36 | this.longPressFired = false 37 | 38 | return this.triggerEvent(action) 39 | } 40 | } else if (action === ButtonAction.Release) { 41 | this.isPressed = false 42 | let result = false 43 | 44 | if (Date.now() - this.pressTimestamp <= 500) { 45 | result = this.triggerEvent(ButtonAction.Click) || result 46 | } else if (!this.longPressFired) { 47 | result = this.triggerEvent(ButtonAction.LongPress) || result 48 | } 49 | 50 | return this.triggerEvent(action) || result 51 | } 52 | 53 | return false 54 | } 55 | 56 | private triggerEvent(action: ButtonAction): boolean { 57 | // iterate upwards so the deeper nodes receive the event earlier 58 | for (let i = GlobalEventManager.handlers.length - 1; i >= 0; i--) { 59 | const handler = GlobalEventManager.handlers[i] 60 | if (handler.type === this.type && handler.buttonAction === action && handler.handler()) { 61 | return true 62 | } 63 | } 64 | 65 | if (this.type === EventType.HomeButton) { 66 | if (action === ButtonAction.Click) { 67 | Navigator.goBack() 68 | } else if (action === ButtonAction.LongPress) { 69 | Navigator.goHome() 70 | } 71 | } else if (this.type === EventType.ShortcutButton && action === ButtonAction.Click) { 72 | const { x: scrollX, y: scrollY } = ViewManager.getScrollOffset() 73 | const x = ViewManager.screenWidth / 2 + scrollX 74 | const y = ViewManager.screenHeight / 2 + scrollY 75 | const target = RenderedTree.hitTest(x, y) 76 | 77 | if (target !== null) { 78 | PointerEventManager.queueEvent({ 79 | target: target.id, 80 | x: x, 81 | y: y, 82 | id: Number.MAX_SAFE_INTEGER, 83 | type: PointerEventType.DOWN, 84 | timestamp: Date.now(), 85 | capture: () => {}, 86 | }) 87 | 88 | PointerEventManager.queueEvent({ 89 | target: target.id, 90 | x: x, 91 | y: y, 92 | id: Number.MAX_SAFE_INTEGER, 93 | type: PointerEventType.UP, 94 | timestamp: Date.now(), 95 | capture: () => {}, 96 | }) 97 | } 98 | } 99 | 100 | return false 101 | } 102 | } 103 | 104 | export abstract class GlobalEventManager { 105 | /** @internal */ 106 | static handlers: EventHandler[] = [] 107 | 108 | private static homeButtonManager = new ButtonEventManager(EventType.HomeButton) 109 | private static shortcutButtonManager = new ButtonEventManager(EventType.ShortcutButton) 110 | 111 | public static clearHandlers() { 112 | GlobalEventManager.handlers = [] 113 | } 114 | 115 | public static registerHandler(handler: EventHandler) { 116 | GlobalEventManager.handlers.push(handler) 117 | } 118 | 119 | public static dispatchCrownEvent(delta: number): boolean { 120 | // iterate upwards so the deeper nodes receive the event earlier 121 | for (let i = GlobalEventManager.handlers.length - 1; i >= 0; i--) { 122 | const handler = GlobalEventManager.handlers[i] 123 | if (handler.type === EventType.Crown && handler.handler(delta)) { 124 | return true 125 | } 126 | } 127 | 128 | return false 129 | } 130 | 131 | public static dispatchGestureEvent(gesture: GestureType): boolean { 132 | // iterate upwards so the deeper nodes receive the event earlier 133 | for (let i = GlobalEventManager.handlers.length - 1; i >= 0; i--) { 134 | const handler = GlobalEventManager.handlers[i] 135 | if (handler.type === EventType.Gesture && handler.handler(gesture)) { 136 | return true 137 | } 138 | } 139 | 140 | return false 141 | } 142 | 143 | public static dispatchButtonEvent(type: EventType, action: ButtonAction): boolean { 144 | switch (type) { 145 | case EventType.HomeButton: 146 | return GlobalEventManager.homeButtonManager.dispatchEvent(action) 147 | case EventType.ShortcutButton: 148 | return GlobalEventManager.shortcutButtonManager.dispatchEvent(action) 149 | } 150 | 151 | return false 152 | } 153 | 154 | public static tick() { 155 | this.homeButtonManager.tick() 156 | this.shortcutButtonManager.tick() 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/__tests__/StackLayout.test.ts: -------------------------------------------------------------------------------- 1 | import { WorkingTree } from '../working_tree/WorkingTree' 2 | import { Stack } from '../working_tree/views/Stack' 3 | import { StackConfig } from '../working_tree/props/StackConfig' 4 | import { StackAlignment } from '../working_tree/props/types' 5 | import { Config } from '../working_tree/props/BaseConfig' 6 | import { Renderer } from '../renderer/Renderer' 7 | import { DummyViewManager } from '../renderer/DummyViewManager' 8 | import { setViewManager } from '../renderer/ViewManager' 9 | import { RenderedTree } from '../renderer/RenderedTree' 10 | 11 | jest.useFakeTimers() 12 | setViewManager(new DummyViewManager()) 13 | 14 | function getRenderedTreeString() { 15 | return JSON.stringify(RenderedTree.current, undefined, 2) 16 | } 17 | 18 | afterEach(() => { 19 | WorkingTree.dropAll() 20 | jest.setSystemTime(0) 21 | }) 22 | 23 | test('Children of Stack(alignment=TopStart) get positioned correctly', () => { 24 | Stack( 25 | StackConfig('stack').width(400).height(400).alignment(StackAlignment.TopStart).padding(10), 26 | () => { 27 | Stack(Config('inner.1').width(200).height(200)) 28 | Stack(Config('inner.2').width(100).height(100)) 29 | } 30 | ) 31 | 32 | WorkingTree.performUpdate() 33 | Renderer.commit(WorkingTree.root) 34 | Renderer.render() 35 | 36 | expect(getRenderedTreeString()).toMatchSnapshot() 37 | }) 38 | 39 | test('Children of Stack(alignment=TopCenter) get positioned correctly', () => { 40 | Stack( 41 | StackConfig('stack').width(400).height(400).alignment(StackAlignment.TopCenter).padding(10), 42 | () => { 43 | Stack(Config('inner.1').width(200).height(200)) 44 | Stack(Config('inner.2').width(100).height(100)) 45 | } 46 | ) 47 | 48 | WorkingTree.performUpdate() 49 | Renderer.commit(WorkingTree.root) 50 | Renderer.render() 51 | 52 | expect(getRenderedTreeString()).toMatchSnapshot() 53 | }) 54 | 55 | test('Children of Stack(alignment=TopEnd) get positioned correctly', () => { 56 | Stack( 57 | StackConfig('stack').width(400).height(400).alignment(StackAlignment.TopEnd).padding(10), 58 | () => { 59 | Stack(Config('inner.1').width(200).height(200)) 60 | Stack(Config('inner.2').width(100).height(100)) 61 | } 62 | ) 63 | 64 | WorkingTree.performUpdate() 65 | Renderer.commit(WorkingTree.root) 66 | Renderer.render() 67 | 68 | expect(getRenderedTreeString()).toMatchSnapshot() 69 | }) 70 | 71 | test('Children of Stack(alignment=CenterStart) get positioned correctly', () => { 72 | Stack( 73 | StackConfig('stack').width(400).height(400).alignment(StackAlignment.CenterStart).padding(10), 74 | () => { 75 | Stack(Config('inner.1').width(200).height(200)) 76 | Stack(Config('inner.2').width(100).height(100)) 77 | } 78 | ) 79 | 80 | WorkingTree.performUpdate() 81 | Renderer.commit(WorkingTree.root) 82 | Renderer.render() 83 | 84 | expect(getRenderedTreeString()).toMatchSnapshot() 85 | }) 86 | 87 | test('Children of Stack(alignment=Center) get positioned correctly', () => { 88 | Stack( 89 | StackConfig('stack').width(400).height(400).alignment(StackAlignment.Center).padding(10), 90 | () => { 91 | Stack(Config('inner.1').width(200).height(200)) 92 | Stack(Config('inner.2').width(100).height(100)) 93 | } 94 | ) 95 | 96 | WorkingTree.performUpdate() 97 | Renderer.commit(WorkingTree.root) 98 | Renderer.render() 99 | 100 | expect(getRenderedTreeString()).toMatchSnapshot() 101 | }) 102 | 103 | test('Children of Stack(alignment=CenterEnd) get positioned correctly', () => { 104 | Stack( 105 | StackConfig('stack').width(400).height(400).alignment(StackAlignment.CenterEnd).padding(10), 106 | () => { 107 | Stack(Config('inner.1').width(200).height(200)) 108 | Stack(Config('inner.2').width(100).height(100)) 109 | } 110 | ) 111 | 112 | WorkingTree.performUpdate() 113 | Renderer.commit(WorkingTree.root) 114 | Renderer.render() 115 | 116 | expect(getRenderedTreeString()).toMatchSnapshot() 117 | }) 118 | 119 | test('Children of Stack(alignment=BottomStart) get positioned correctly', () => { 120 | Stack( 121 | StackConfig('stack').width(400).height(400).alignment(StackAlignment.BottomStart).padding(10), 122 | () => { 123 | Stack(Config('inner.1').width(200).height(200)) 124 | Stack(Config('inner.2').width(100).height(100)) 125 | } 126 | ) 127 | 128 | WorkingTree.performUpdate() 129 | Renderer.commit(WorkingTree.root) 130 | Renderer.render() 131 | 132 | expect(getRenderedTreeString()).toMatchSnapshot() 133 | }) 134 | 135 | test('Children of Stack(alignment=BottomCenter) get positioned correctly', () => { 136 | Stack( 137 | StackConfig('stack').width(400).height(400).alignment(StackAlignment.BottomCenter).padding(10), 138 | () => { 139 | Stack(Config('inner.1').width(200).height(200)) 140 | Stack(Config('inner.2').width(100).height(100)) 141 | } 142 | ) 143 | 144 | WorkingTree.performUpdate() 145 | Renderer.commit(WorkingTree.root) 146 | Renderer.render() 147 | 148 | expect(getRenderedTreeString()).toMatchSnapshot() 149 | }) 150 | 151 | test('Children of Stack(alignment=BottomEnd) get positioned correctly', () => { 152 | Stack( 153 | StackConfig('stack').width(400).height(400).alignment(StackAlignment.BottomEnd).padding(10), 154 | () => { 155 | Stack(Config('inner.1').width(200).height(200)) 156 | Stack(Config('inner.2').width(100).height(100)) 157 | } 158 | ) 159 | 160 | WorkingTree.performUpdate() 161 | Renderer.commit(WorkingTree.root) 162 | Renderer.render() 163 | 164 | expect(getRenderedTreeString()).toMatchSnapshot() 165 | }) 166 | -------------------------------------------------------------------------------- /@zapp-framework/ui/src/Button.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConfigType, 3 | ConfigBuilder, 4 | Stack, 5 | StackConfig, 6 | StackAlignment, 7 | Zapp, 8 | Platform, 9 | remember, 10 | withTiming, 11 | Easing, 12 | Color, 13 | } from '@zapp-framework/core' 14 | import { popTextColor, popTextSize, pushTextColor, pushTextSize } from './Text.js' 15 | import { Theme } from './Theme.js' 16 | 17 | const OUTER_PADDING = 4 18 | const TEXT_SIZE = 32 19 | 20 | export enum ButtonStyle { 21 | Filled, 22 | Outlined, 23 | BodyOnly, 24 | } 25 | 26 | interface ButtonConfigType extends ConfigType { 27 | style: ButtonStyle 28 | onPress?: () => void 29 | background?: number 30 | } 31 | 32 | export class ButtonConfigBuilder extends ConfigBuilder { 33 | protected config: ButtonConfigType 34 | 35 | constructor(id: string) { 36 | super(id) 37 | this.config.style = ButtonStyle.Filled 38 | } 39 | 40 | style(style: ButtonStyle) { 41 | this.config.style = style 42 | return this 43 | } 44 | 45 | onPress(handler: () => void) { 46 | this.config.onPress = handler 47 | return this 48 | } 49 | 50 | background(color: number) { 51 | this.config.background = color 52 | return this 53 | } 54 | 55 | build(): ButtonConfigType { 56 | return this.config 57 | } 58 | } 59 | 60 | export function ButtonConfig(id: string): ButtonConfigBuilder { 61 | return new ButtonConfigBuilder(id) 62 | } 63 | 64 | export function Button(config: ButtonConfigBuilder, body: () => void) { 65 | const rawConfig = config.build() 66 | 67 | Stack(StackConfig(`${rawConfig.id}#wrapper`), () => { 68 | const outerPadding = remember(OUTER_PADDING) 69 | 70 | Stack( 71 | StackConfig(`${rawConfig.id}#outerPadding`) 72 | .alignment(StackAlignment.Center) 73 | .positionAbsolutely(rawConfig.isPositionedAbsolutely!) 74 | .padding(px(outerPadding.value / 2), px(outerPadding.value)), 75 | () => { 76 | const pressed = remember(false) 77 | const pressPosition = remember({ x: 0, y: 0 }) 78 | const background = remember(rawConfig.background ?? Theme.primary) 79 | const timer = remember(0) 80 | 81 | const cancelPress = () => { 82 | pressed.value = false 83 | outerPadding.value = OUTER_PADDING 84 | background.value = rawConfig.background ?? Theme.primary 85 | } 86 | 87 | const backgroundConfig = StackConfig(`${rawConfig.id}#background`) 88 | .positionAbsolutely(true) 89 | .offset(outerPadding.value, outerPadding.value / 2) 90 | .cornerRadius(px(TEXT_SIZE + OUTER_PADDING)) 91 | .padding( 92 | px(TEXT_SIZE * 1.25 + OUTER_PADDING - outerPadding.value), 93 | px(TEXT_SIZE * 0.35 + (OUTER_PADDING - outerPadding.value) / 2), 94 | px(TEXT_SIZE * 1.25 + OUTER_PADDING - outerPadding.value), 95 | px(TEXT_SIZE / 2 + (OUTER_PADDING - outerPadding.value) / 2) 96 | ) 97 | .onPointerDown((e) => { 98 | pressed.value = true 99 | pressPosition.value = { x: e.x, y: e.y } 100 | outerPadding.value = 0 101 | background.value = Color.accent(rawConfig.background ?? Theme.primary, 0.1) 102 | 103 | timer.value = withTiming(0, { 104 | duration: 500, 105 | onEnd: (finished) => { 106 | if (finished) { 107 | cancelPress() 108 | } 109 | }, 110 | }) 111 | }) 112 | .onPointerUp(() => { 113 | if (pressed.value) { 114 | cancelPress() 115 | rawConfig.onPress?.() 116 | } 117 | }) 118 | .onPointerMove((e) => { 119 | if ( 120 | Math.sqrt( 121 | (e.x - pressPosition.value.x) * (e.x - pressPosition.value.x) + 122 | (e.y - pressPosition.value.y) * (e.y - pressPosition.value.y) 123 | ) > px(24) 124 | ) { 125 | cancelPress() 126 | } 127 | }) 128 | .onPointerLeave(cancelPress) 129 | 130 | if (Zapp.platform === Platform.Web) { 131 | backgroundConfig.padding( 132 | px(TEXT_SIZE * 1.25 + OUTER_PADDING - outerPadding.value), 133 | px(TEXT_SIZE / 2 + (OUTER_PADDING - outerPadding.value) / 2), 134 | px(TEXT_SIZE * 1.25 + OUTER_PADDING - outerPadding.value), 135 | px(TEXT_SIZE * 0.4 + (OUTER_PADDING - outerPadding.value) / 2) 136 | ) 137 | } 138 | 139 | let backgroundColor: number | undefined = undefined 140 | let borderColor: number | undefined = undefined 141 | 142 | if (rawConfig.style === ButtonStyle.Filled) { 143 | backgroundColor = background.value 144 | pushTextColor(Theme.onPrimary) 145 | } else if (rawConfig.style === ButtonStyle.Outlined) { 146 | backgroundConfig.borderWidth(px(2)) 147 | borderColor = Theme.primary 148 | pushTextColor(Theme.primary) 149 | 150 | if (rawConfig.background !== undefined) { 151 | backgroundColor = background.value 152 | } 153 | } else { 154 | pushTextColor(Theme.primary) 155 | 156 | if (rawConfig.background !== undefined) { 157 | backgroundColor = background.value 158 | } 159 | } 160 | 161 | backgroundConfig.background(backgroundColor!) 162 | backgroundConfig.borderColor(borderColor!) 163 | 164 | pushTextSize(px(TEXT_SIZE)) 165 | 166 | Stack(backgroundConfig, body) 167 | 168 | popTextColor() 169 | popTextSize() 170 | } 171 | ) 172 | }) 173 | } 174 | -------------------------------------------------------------------------------- /watch-test/page/index.js: -------------------------------------------------------------------------------- 1 | import '@zapp-framework/watch' 2 | import { ActivityIndicator, ActivityIndicatorConfig, Theme } from '@zapp-framework/ui' 3 | import { 4 | SimpleScreen, 5 | Stack, 6 | StackConfig, 7 | StackAlignment, 8 | Config, 9 | BareText, 10 | TextConfig, 11 | remember, 12 | sideEffect, 13 | withTiming, 14 | Column, 15 | Row, 16 | RowConfig, 17 | Alignment, 18 | Arrangement, 19 | Easing, 20 | ColumnConfig, 21 | ArcConfig, 22 | Navigator, 23 | registerCrownEventHandler, 24 | Image, 25 | ImageConfig, 26 | } from '@zapp-framework/core' 27 | 28 | let cycle = [ 29 | Arrangement.SpaceEvenly, 30 | Arrangement.SpaceBetween, 31 | Arrangement.Start, 32 | Arrangement.Center, 33 | Arrangement.End, 34 | Arrangement.SpaceAround, 35 | ] 36 | 37 | SimpleScreen(Config('screen'), () => { 38 | Stack(StackConfig('stack').fillSize().alignment(StackAlignment.Center), () => { 39 | Column( 40 | ColumnConfig('column').fillWidth(0.75).fillHeight(0.75).background(0xff0000).padding(10), 41 | () => { 42 | const weight = remember(1) 43 | sideEffect(() => { 44 | weight.value = withTiming(2, { duration: 3000, easing: Easing.easeOutQuad }) 45 | }) 46 | const background = remember(0x00ff00) 47 | Stack( 48 | StackConfig('row1') 49 | .fillWidth(1) 50 | .weight(1) 51 | .background(background.value) 52 | .alignment(StackAlignment.Center) 53 | .onPointerDown(() => { 54 | background.value = 0x00aa00 55 | }) 56 | .onPointerUp(() => { 57 | background.value = 0x00ff00 58 | Navigator.navigate('page/pager') 59 | }) 60 | .onPointerEnter(() => { 61 | background.value = 0x00aa00 62 | }) 63 | .onPointerLeave(() => { 64 | background.value = 0x00ff00 65 | }), 66 | () => { 67 | const textVisible = remember(false) 68 | const angle = remember(0) 69 | 70 | Stack( 71 | StackConfig('wrapper') 72 | .padding(15) 73 | .background(Theme.surface) 74 | .onPointerDown(() => { 75 | textVisible.value = !textVisible.value 76 | angle.value = 0 77 | }), 78 | () => { 79 | sideEffect(() => { 80 | if (!textVisible.value) { 81 | angle.value = withTiming(360, { duration: 5000, easing: Easing.easeInOutCubic }) 82 | } 83 | }, textVisible.value) 84 | 85 | if (!textVisible.value) { 86 | Image( 87 | ImageConfig('img') 88 | .width(80) 89 | .height(80) 90 | .origin(40, 40) 91 | .innerOffset(8, 8) 92 | .rotation(angle.value), 93 | 'zapp.png' 94 | ) 95 | } else { 96 | ActivityIndicator(ActivityIndicatorConfig('ac').size(60).lineWidth(10)) 97 | } 98 | } 99 | ) 100 | } 101 | ) 102 | 103 | const arrangement = remember(Arrangement.SpaceAround) 104 | const border = remember(0) 105 | 106 | Row( 107 | RowConfig('row2') 108 | .fillWidth(1) 109 | .weight(weight.value) 110 | .background(0x0000ff) 111 | .alignment(Alignment.Center) 112 | .arrangement(arrangement.value), 113 | () => { 114 | const start = remember({ x: 0, y: 0 }) 115 | const offset = remember({ x: 0, y: 0 }) 116 | Stack( 117 | StackConfig('stack.1') 118 | .fillHeight(0.3) 119 | .width(30) 120 | .background(0xff00ff) 121 | .borderWidth(10) 122 | .borderColor(0x00ff00) 123 | .onPointerDown(() => { 124 | Navigator.navigate('page/page1', { data: 'from home' }) 125 | }) 126 | ) 127 | Stack( 128 | StackConfig('stack.2') 129 | .fillHeight(0.6) 130 | .width(60) 131 | .background(0xffff00) 132 | .borderWidth(border.value) 133 | .cornerRadius(30) 134 | .borderColor(0xaa5533) 135 | .onPointerDown(() => { 136 | const next = cycle.shift() 137 | cycle.push(next) 138 | arrangement.value = next 139 | 140 | if (border.value === 0) { 141 | border.value = 10 142 | } else { 143 | border.value = 0 144 | } 145 | }) 146 | ) 147 | Stack( 148 | StackConfig('stack.3') 149 | .fillHeight(0.9) 150 | .width(90) 151 | .background(0x00ffff) 152 | .borderWidth(10) 153 | .borderColor(0xffaa33) 154 | .offset(offset.value.x, offset.value.y) 155 | .onPointerDown((e) => { 156 | e.capture() 157 | start.value = { x: e.x, y: e.y } 158 | }) 159 | .onPointerMove((e) => { 160 | offset.value = { 161 | x: offset.value.x + e.x - start.value.x, 162 | y: offset.value.y + e.y - start.value.y, 163 | } 164 | start.value = { x: e.x, y: e.y } 165 | }) 166 | ) 167 | } 168 | ) 169 | } 170 | ) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/__tests__/RowLayout.test.ts: -------------------------------------------------------------------------------- 1 | import { WorkingTree } from '../working_tree/WorkingTree' 2 | import { Stack } from '../working_tree/views/Stack' 3 | import { RowConfig } from '../working_tree/props/RowConfig' 4 | import { Alignment, Arrangement } from '../working_tree/props/types' 5 | import { Config } from '../working_tree/props/BaseConfig' 6 | import { Renderer } from '../renderer/Renderer' 7 | import { DummyViewManager } from '../renderer/DummyViewManager' 8 | import { Row } from '../working_tree/views/Row' 9 | import { setViewManager } from '../renderer/ViewManager' 10 | import { RenderedTree } from '../renderer/RenderedTree' 11 | 12 | jest.useFakeTimers() 13 | setViewManager(new DummyViewManager()) 14 | 15 | function getRenderedTreeString() { 16 | return JSON.stringify(RenderedTree.current, undefined, 2) 17 | } 18 | 19 | afterEach(() => { 20 | WorkingTree.dropAll() 21 | jest.setSystemTime(0) 22 | }) 23 | 24 | test('Children of Row(alignment=Start) get positioned correctly', () => { 25 | Row(RowConfig('row').width(400).height(400).alignment(Alignment.Start).padding(10), () => { 26 | Stack(Config('inner.1').width(50).height(50)) 27 | Stack(Config('inner.2').width(80).height(80)) 28 | Stack(Config('inner.3').width(100).height(100)) 29 | }) 30 | 31 | WorkingTree.performUpdate() 32 | Renderer.commit(WorkingTree.root) 33 | Renderer.render() 34 | 35 | expect(getRenderedTreeString()).toMatchSnapshot() 36 | }) 37 | 38 | test('Children of Row(alignment=Center) get positioned correctly', () => { 39 | Row(RowConfig('row').width(400).height(400).alignment(Alignment.Center).padding(10), () => { 40 | Stack(Config('inner.1').width(50).height(50)) 41 | Stack(Config('inner.2').width(80).height(80)) 42 | Stack(Config('inner.3').width(100).height(100)) 43 | }) 44 | 45 | WorkingTree.performUpdate() 46 | Renderer.commit(WorkingTree.root) 47 | Renderer.render() 48 | 49 | expect(getRenderedTreeString()).toMatchSnapshot() 50 | }) 51 | 52 | test('Children of Row(alignment=End) get positioned correctly', () => { 53 | Row(RowConfig('row').width(400).height(400).alignment(Alignment.End).padding(10), () => { 54 | Stack(Config('inner.1').width(50).height(50)) 55 | Stack(Config('inner.2').width(80).height(80)) 56 | Stack(Config('inner.3').width(100).height(100)) 57 | }) 58 | 59 | WorkingTree.performUpdate() 60 | Renderer.commit(WorkingTree.root) 61 | Renderer.render() 62 | 63 | expect(getRenderedTreeString()).toMatchSnapshot() 64 | }) 65 | 66 | test('Children of Row(arrangement=Start) get positioned correctly', () => { 67 | Row(RowConfig('row').width(400).height(400).arrangement(Arrangement.Start).padding(10), () => { 68 | Stack(Config('inner.1').width(50).height(50)) 69 | Stack(Config('inner.2').width(80).height(80)) 70 | Stack(Config('inner.3').width(100).height(100)) 71 | }) 72 | 73 | WorkingTree.performUpdate() 74 | Renderer.commit(WorkingTree.root) 75 | Renderer.render() 76 | 77 | expect(getRenderedTreeString()).toMatchSnapshot() 78 | }) 79 | 80 | test('Children of Row(arrangement=Center) get positioned correctly', () => { 81 | Row(RowConfig('row').width(400).height(400).arrangement(Arrangement.Center).padding(10), () => { 82 | Stack(Config('inner.1').width(50).height(50)) 83 | Stack(Config('inner.2').width(80).height(80)) 84 | Stack(Config('inner.3').width(100).height(100)) 85 | }) 86 | 87 | WorkingTree.performUpdate() 88 | Renderer.commit(WorkingTree.root) 89 | Renderer.render() 90 | 91 | expect(getRenderedTreeString()).toMatchSnapshot() 92 | }) 93 | 94 | test('Children of Row(arrangement=End) get positioned correctly', () => { 95 | Row(RowConfig('row').width(400).height(400).arrangement(Arrangement.End).padding(10), () => { 96 | Stack(Config('inner.1').width(50).height(50)) 97 | Stack(Config('inner.2').width(80).height(80)) 98 | Stack(Config('inner.3').width(100).height(100)) 99 | }) 100 | 101 | WorkingTree.performUpdate() 102 | Renderer.commit(WorkingTree.root) 103 | Renderer.render() 104 | 105 | expect(getRenderedTreeString()).toMatchSnapshot() 106 | }) 107 | 108 | test('Children of Row(arrangement=SpaceBetween) get positioned correctly', () => { 109 | Row( 110 | RowConfig('row').width(400).height(400).arrangement(Arrangement.SpaceBetween).padding(10), 111 | () => { 112 | Stack(Config('inner.1').width(50).height(50)) 113 | Stack(Config('inner.2').width(80).height(80)) 114 | Stack(Config('inner.3').width(100).height(100)) 115 | } 116 | ) 117 | 118 | WorkingTree.performUpdate() 119 | Renderer.commit(WorkingTree.root) 120 | Renderer.render() 121 | 122 | expect(getRenderedTreeString()).toMatchSnapshot() 123 | }) 124 | 125 | test('Children of Row(arrangement=SpaceAround) get positioned correctly', () => { 126 | Row( 127 | RowConfig('row').width(400).height(400).arrangement(Arrangement.SpaceAround).padding(10), 128 | () => { 129 | Stack(Config('inner.1').width(50).height(50)) 130 | Stack(Config('inner.2').width(80).height(80)) 131 | Stack(Config('inner.3').width(100).height(100)) 132 | } 133 | ) 134 | 135 | WorkingTree.performUpdate() 136 | Renderer.commit(WorkingTree.root) 137 | Renderer.render() 138 | 139 | expect(getRenderedTreeString()).toMatchSnapshot() 140 | }) 141 | 142 | test('Children of Row(arrangement=SpaceEvenly) get positioned correctly', () => { 143 | Row( 144 | RowConfig('row').width(400).height(400).arrangement(Arrangement.SpaceEvenly).padding(10), 145 | () => { 146 | Stack(Config('inner.1').width(50).height(50)) 147 | Stack(Config('inner.2').width(80).height(80)) 148 | Stack(Config('inner.3').width(100).height(100)) 149 | } 150 | ) 151 | 152 | WorkingTree.performUpdate() 153 | Renderer.commit(WorkingTree.root) 154 | Renderer.render() 155 | 156 | expect(getRenderedTreeString()).toMatchSnapshot() 157 | }) 158 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/working_tree/WorkingTree.ts: -------------------------------------------------------------------------------- 1 | import { NodeType } from '../NodeType.js' 2 | import { PrefixTree } from '../PrefixTree.js' 3 | import { EffectNode } from './EffectNode.js' 4 | import { EventNode } from './EventNode.js' 5 | import { ConfigType } from './props/types.js' 6 | import { RememberNode } from './RememberNode.js' 7 | import { SavedTreeState } from './SavedTreeState.js' 8 | import { ViewNode, ViewNodeProps } from './ViewNode.js' 9 | import { CustomViewProps } from './views/Custom.js' 10 | import { WorkingNode } from './WorkingNode.js' 11 | 12 | export const ROOT_ID = '#__root' 13 | 14 | export class RootNode extends WorkingNode { 15 | public savedState?: SavedTreeState 16 | public children: WorkingNode[] 17 | public config: ConfigType 18 | 19 | constructor() { 20 | super({ 21 | id: ROOT_ID, 22 | type: NodeType.Root, 23 | }) 24 | 25 | this.config = { id: ROOT_ID } 26 | this.children = [] 27 | } 28 | 29 | public drop(newSubtreeRoot: WorkingNode): void { 30 | super.drop(newSubtreeRoot) 31 | 32 | for (const child of this.children) { 33 | child.drop(newSubtreeRoot) 34 | } 35 | } 36 | } 37 | 38 | export abstract class WorkingTree { 39 | private static _root: RootNode = new RootNode() 40 | private static _current: WorkingNode = WorkingTree._root 41 | 42 | private static updatePaths = new PrefixTree() 43 | private static updateRequested = false 44 | 45 | static get current() { 46 | return this._current 47 | } 48 | 49 | static get root() { 50 | return this._root 51 | } 52 | 53 | public static saveState() { 54 | return new SavedTreeState(WorkingTree.root) 55 | } 56 | 57 | public static restoreState(savedState: SavedTreeState) { 58 | WorkingTree.root.savedState = savedState 59 | } 60 | 61 | public static isRestoringState() { 62 | return WorkingTree.root.savedState !== undefined 63 | } 64 | 65 | public static withContext(context: WorkingNode, fun?: () => void) { 66 | if (fun !== undefined) { 67 | const previousContext = WorkingTree.current 68 | WorkingTree._current = context 69 | fun() 70 | WorkingTree._current.reset() 71 | WorkingTree._current = previousContext 72 | } 73 | } 74 | 75 | public static queueUpdate(context: WorkingNode) { 76 | this.updatePaths.addPath(context.path.concat(context.id)) 77 | } 78 | 79 | public static requestUpdate() { 80 | this.updateRequested = true 81 | } 82 | 83 | public static hasUpdates() { 84 | return this.updateRequested || !this.updatePaths.isEmpty() 85 | } 86 | 87 | public static performUpdate() { 88 | WorkingTree.root.savedState = undefined 89 | 90 | const pathsToUpdate = this.updatePaths.getPaths() 91 | this.updatePaths.clear() 92 | this.updateRequested = false 93 | 94 | for (const path of pathsToUpdate) { 95 | const nodeToUpdate = WorkingTree.root.getNodeFromPath(path) as ViewNode 96 | 97 | if (nodeToUpdate !== null) { 98 | const recompositionContext = new ViewNode({ 99 | id: nodeToUpdate.id, 100 | type: NodeType.Recomposing, 101 | config: nodeToUpdate.config, 102 | body: nodeToUpdate.body, 103 | }) 104 | 105 | recompositionContext.override = nodeToUpdate 106 | recompositionContext.rememberedContext = nodeToUpdate 107 | recompositionContext.path = nodeToUpdate.path 108 | 109 | WorkingTree.withContext(recompositionContext, nodeToUpdate.body!) 110 | 111 | const droppedChildren = nodeToUpdate.children 112 | nodeToUpdate.children = recompositionContext.children 113 | 114 | for (const child of droppedChildren) { 115 | child.drop(nodeToUpdate) 116 | } 117 | } 118 | } 119 | } 120 | 121 | public static create( 122 | parent: WorkingNode, 123 | props: ViewNodeProps, 124 | customViewProps?: CustomViewProps 125 | ) { 126 | const result = new ViewNode(props) 127 | 128 | // new view nodes may only be created inside another view node 129 | const currentView = WorkingTree.current as ViewNode 130 | 131 | result.customViewProps = customViewProps 132 | result.parent = currentView.override ?? WorkingTree.current 133 | result.rememberedContext = currentView.rememberedContext 134 | result.path = parent.path.concat(parent.id) 135 | 136 | return result 137 | } 138 | 139 | public static remember(parent: WorkingNode) { 140 | // remember may only be called inside view node 141 | const currentView = WorkingTree.current as ViewNode 142 | 143 | const result = new RememberNode({ 144 | id: (currentView.nextActionId++).toString(), 145 | type: NodeType.Remember, 146 | }) 147 | 148 | result.parent = currentView.override ?? WorkingTree.current 149 | result.path = parent.path.concat(parent.id) 150 | 151 | return result 152 | } 153 | 154 | public static effect(parent: WorkingNode) { 155 | // effects may only be created inside view node 156 | const currentView = WorkingTree.current as ViewNode 157 | 158 | const result = new EffectNode({ 159 | id: (currentView.nextActionId++).toString(), 160 | type: NodeType.Effect, 161 | }) 162 | 163 | result.parent = currentView.override ?? WorkingTree.current 164 | result.path = parent.path.concat(parent.id) 165 | 166 | return result 167 | } 168 | 169 | public static event(parent: WorkingNode) { 170 | // events may only be created inside view node 171 | const currentView = WorkingTree.current as ViewNode 172 | 173 | const result = new EventNode({ 174 | id: (currentView.nextActionId++).toString(), 175 | type: NodeType.Event, 176 | }) 177 | 178 | result.parent = currentView.override ?? WorkingTree.current 179 | result.path = parent.path.concat(parent.id) 180 | 181 | return result 182 | } 183 | 184 | public static dropAll() { 185 | const newRoot = new RootNode() 186 | newRoot.savedState = WorkingTree.root.savedState 187 | 188 | WorkingTree._root.drop(newRoot) 189 | WorkingTree._root = newRoot 190 | WorkingTree._current = newRoot 191 | 192 | WorkingTree.requestUpdate() 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /@zapp-framework/core/src/__tests__/ColumnLayout.test.ts: -------------------------------------------------------------------------------- 1 | import { WorkingTree } from '../working_tree/WorkingTree' 2 | import { Stack } from '../working_tree/views/Stack' 3 | import { ColumnConfig } from '../working_tree/props/ColumnConfig' 4 | import { Alignment, Arrangement } from '../working_tree/props/types' 5 | import { Config } from '../working_tree/props/BaseConfig' 6 | import { Renderer } from '../renderer/Renderer' 7 | import { RenderedTree } from '../renderer/RenderedTree' 8 | import { DummyViewManager } from '../renderer/DummyViewManager' 9 | import { Column } from '../working_tree/views/Column' 10 | import { setViewManager } from '../renderer/ViewManager' 11 | 12 | jest.useFakeTimers() 13 | setViewManager(new DummyViewManager()) 14 | 15 | function getRenderedTreeString() { 16 | return JSON.stringify(RenderedTree.current, undefined, 2) 17 | } 18 | 19 | afterEach(() => { 20 | WorkingTree.dropAll() 21 | jest.setSystemTime(0) 22 | }) 23 | 24 | test('Children of Column(alignment=Start) get positioned correctly', () => { 25 | Column( 26 | ColumnConfig('column').width(400).height(400).alignment(Alignment.Start).padding(10), 27 | () => { 28 | Stack(Config('inner.1').width(50).height(50)) 29 | Stack(Config('inner.2').width(80).height(80)) 30 | Stack(Config('inner.3').width(100).height(100)) 31 | } 32 | ) 33 | 34 | WorkingTree.performUpdate() 35 | Renderer.commit(WorkingTree.root) 36 | Renderer.render() 37 | 38 | expect(getRenderedTreeString()).toMatchSnapshot() 39 | }) 40 | 41 | test('Children of Column(alignment=Center) get positioned correctly', () => { 42 | Column( 43 | ColumnConfig('column').width(400).height(400).alignment(Alignment.Center).padding(10), 44 | () => { 45 | Stack(Config('inner.1').width(50).height(50)) 46 | Stack(Config('inner.2').width(80).height(80)) 47 | Stack(Config('inner.3').width(100).height(100)) 48 | } 49 | ) 50 | 51 | WorkingTree.performUpdate() 52 | Renderer.commit(WorkingTree.root) 53 | Renderer.render() 54 | 55 | expect(getRenderedTreeString()).toMatchSnapshot() 56 | }) 57 | 58 | test('Children of Column(alignment=End) get positioned correctly', () => { 59 | Column(ColumnConfig('column').width(400).height(400).alignment(Alignment.End).padding(10), () => { 60 | Stack(Config('inner.1').width(50).height(50)) 61 | Stack(Config('inner.2').width(80).height(80)) 62 | Stack(Config('inner.3').width(100).height(100)) 63 | }) 64 | 65 | WorkingTree.performUpdate() 66 | Renderer.commit(WorkingTree.root) 67 | Renderer.render() 68 | 69 | expect(getRenderedTreeString()).toMatchSnapshot() 70 | }) 71 | 72 | test('Children of Column(arrangement=Start) get positioned correctly', () => { 73 | Column( 74 | ColumnConfig('column').width(400).height(400).arrangement(Arrangement.Start).padding(10), 75 | () => { 76 | Stack(Config('inner.1').width(50).height(50)) 77 | Stack(Config('inner.2').width(80).height(80)) 78 | Stack(Config('inner.3').width(100).height(100)) 79 | } 80 | ) 81 | 82 | WorkingTree.performUpdate() 83 | Renderer.commit(WorkingTree.root) 84 | Renderer.render() 85 | 86 | expect(getRenderedTreeString()).toMatchSnapshot() 87 | }) 88 | 89 | test('Children of Column(arrangement=Center) get positioned correctly', () => { 90 | Column( 91 | ColumnConfig('column').width(400).height(400).arrangement(Arrangement.Center).padding(10), 92 | () => { 93 | Stack(Config('inner.1').width(50).height(50)) 94 | Stack(Config('inner.2').width(80).height(80)) 95 | Stack(Config('inner.3').width(100).height(100)) 96 | } 97 | ) 98 | 99 | WorkingTree.performUpdate() 100 | Renderer.commit(WorkingTree.root) 101 | Renderer.render() 102 | 103 | expect(getRenderedTreeString()).toMatchSnapshot() 104 | }) 105 | 106 | test('Children of Column(arrangement=End) get positioned correctly', () => { 107 | Column( 108 | ColumnConfig('column').width(400).height(400).arrangement(Arrangement.End).padding(10), 109 | () => { 110 | Stack(Config('inner.1').width(50).height(50)) 111 | Stack(Config('inner.2').width(80).height(80)) 112 | Stack(Config('inner.3').width(100).height(100)) 113 | } 114 | ) 115 | 116 | WorkingTree.performUpdate() 117 | Renderer.commit(WorkingTree.root) 118 | Renderer.render() 119 | 120 | expect(getRenderedTreeString()).toMatchSnapshot() 121 | }) 122 | 123 | test('Children of Column(arrangement=SpaceBetween) get positioned correctly', () => { 124 | Column( 125 | ColumnConfig('column').width(400).height(400).arrangement(Arrangement.SpaceBetween).padding(10), 126 | () => { 127 | Stack(Config('inner.1').width(50).height(50)) 128 | Stack(Config('inner.2').width(80).height(80)) 129 | Stack(Config('inner.3').width(100).height(100)) 130 | } 131 | ) 132 | 133 | WorkingTree.performUpdate() 134 | Renderer.commit(WorkingTree.root) 135 | Renderer.render() 136 | 137 | expect(getRenderedTreeString()).toMatchSnapshot() 138 | }) 139 | 140 | test('Children of Column(arrangement=SpaceAround) get positioned correctly', () => { 141 | Column( 142 | ColumnConfig('column').width(400).height(400).arrangement(Arrangement.SpaceAround).padding(10), 143 | () => { 144 | Stack(Config('inner.1').width(50).height(50)) 145 | Stack(Config('inner.2').width(80).height(80)) 146 | Stack(Config('inner.3').width(100).height(100)) 147 | } 148 | ) 149 | 150 | WorkingTree.performUpdate() 151 | Renderer.commit(WorkingTree.root) 152 | Renderer.render() 153 | 154 | expect(getRenderedTreeString()).toMatchSnapshot() 155 | }) 156 | 157 | test('Children of Column(arrangement=SpaceEvenly) get positioned correctly', () => { 158 | Column( 159 | ColumnConfig('column').width(400).height(400).arrangement(Arrangement.SpaceEvenly).padding(10), 160 | () => { 161 | Stack(Config('inner.1').width(50).height(50)) 162 | Stack(Config('inner.2').width(80).height(80)) 163 | Stack(Config('inner.3').width(100).height(100)) 164 | } 165 | ) 166 | 167 | WorkingTree.performUpdate() 168 | Renderer.commit(WorkingTree.root) 169 | Renderer.render() 170 | 171 | expect(getRenderedTreeString()).toMatchSnapshot() 172 | }) 173 | --------------------------------------------------------------------------------