├── .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 |
5 |
6 |
7 |
8 | Web bindings for Zapp framework.
--------------------------------------------------------------------------------
/@zapp-framework/watch/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | ZeppOS bindings for Zapp framework.
--------------------------------------------------------------------------------
/@zapp-framework/ui/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
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 |
--------------------------------------------------------------------------------