├── .babelrc ├── .gitattributes ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── main.yml │ └── pull-request.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE.txt ├── Makefile ├── README.md ├── bundle ├── bundle.go ├── bundle_test.go ├── testdata │ ├── bundle.tar.gz │ ├── excess-files.tar.gz │ └── testapp │ │ ├── a_subdirectory │ │ └── hi.jpg │ │ ├── manifest.yaml │ │ ├── test.txt │ │ ├── test_app.star │ │ └── unused.txt └── write.go ├── cmd ├── buildifier.go ├── check.go ├── community │ ├── community.go │ ├── createmanifest.go │ ├── listicons.go │ ├── loadapp.go │ ├── manifestprompt.go │ ├── validateicons.go │ └── validatemanifest.go ├── config │ └── config.go ├── create.go ├── delete.go ├── devices.go ├── encrypt.go ├── format.go ├── lint.go ├── list.go ├── login.go ├── private │ ├── bundle.go │ ├── create.go │ ├── delete.go │ ├── deploy.go │ ├── list.go │ ├── logs.go │ ├── private.go │ └── upload.go ├── profile.go ├── push.go ├── render.go ├── serve.go ├── setauth.go └── version.go ├── dist ├── .gitignore ├── dist.go ├── dist_js.go ├── index.html └── static │ └── keepdir ├── docs ├── BUILD.md ├── BUILD_WINDOWS.md ├── CODE_OF_CONDUCT.md ├── animation.md ├── authoring_apps.md ├── fonts.md ├── gen_widget_imgs.go ├── gifs.md ├── img │ ├── 10x20.gif │ ├── 5x8.gif │ ├── 6x10-rounded.gif │ ├── 6x10.gif │ ├── 6x13.gif │ ├── CG-pixel-3x5-mono.gif │ ├── CG-pixel-4x5-mono.gif │ ├── Dina_r400-6.gif │ ├── Typography_Line_Terms.png │ ├── clock.gif │ ├── mobile_1.jpg │ ├── tb-8.gif │ ├── tidbyt_1.png │ ├── tidbyt_2.jpg │ ├── tom-thumb.gif │ ├── tutorial_1.gif │ ├── tutorial_2.gif │ ├── tutorial_3.gif │ ├── tutorial_4.gif │ ├── tutorial_btcicon.png │ ├── widget_Animation_0.gif │ ├── widget_Box_0.gif │ ├── widget_Circle_0.gif │ ├── widget_Column_0.gif │ ├── widget_Column_1.gif │ ├── widget_Marquee_0.gif │ ├── widget_PieChart_0.gif │ ├── widget_Plot_0.gif │ ├── widget_Row_0.gif │ ├── widget_Row_1.gif │ ├── widget_Stack_0.gif │ ├── widget_Text_0.gif │ ├── widget_Transformation_0.gif │ └── widget_WrappedText_0.gif ├── modules.md ├── schema │ ├── color │ │ ├── color.gif │ │ └── example.star │ ├── datetime │ │ ├── datetime.gif │ │ └── example.star │ ├── dropdown │ │ ├── dropdown.gif │ │ └── example.star │ ├── generated │ │ └── example.star │ ├── location │ │ ├── example.star │ │ └── location.gif │ ├── locationbased │ │ ├── example.star │ │ └── locationbased.gif │ ├── oauth2 │ │ ├── example.star │ │ └── oauth2.gif │ ├── photoselect │ │ ├── example.star │ │ └── photoselect.gif │ ├── schema.md │ ├── text │ │ ├── example.star │ │ └── text.gif │ ├── toggle │ │ ├── example.star │ │ └── toggle.gif │ └── typeahead │ │ ├── example.star │ │ └── typeahead.gif ├── tutorial.md └── widgets.md ├── encode ├── encode.go ├── encode_bench_test.go ├── encode_test.go ├── gif.go ├── webp.go └── webp_js.go ├── examples ├── bitcoin │ ├── bitcoin.star │ └── icon.png ├── clock │ └── clock.star ├── font-preview │ └── font-preview.star ├── hello_world │ └── hello_world.star ├── humanize │ └── humanize.star ├── life │ ├── gosper_glider.txt │ └── life.star ├── qrcode │ └── qrcode.star ├── quadrants │ └── quadrants.star ├── schema_hello_world │ └── schema_hello_world.star └── sunrise │ └── sunrise.star ├── fonts ├── 10x20.bdf ├── 5x8.bdf ├── 6x10-rounded.bdf ├── 6x10.bdf ├── 6x13.bdf ├── CG-pixel-3x5-mono.bdf ├── CG-pixel-4x5-mono.bdf ├── Dina_r400-6.bdf ├── tb-8.bdf └── tom-thumb.bdf ├── globals └── global.go ├── go.mod ├── go.sum ├── icons └── icons.go ├── main.go ├── main_nonjs.go ├── manifest ├── manifest.go ├── manifest_test.go ├── testdata │ ├── manifest.yaml │ └── source.star ├── validate.go └── validate_test.go ├── package-lock.json ├── package.json ├── public └── _headers ├── render ├── animation.go ├── animation │ ├── curve.go │ ├── curve_test.go │ ├── direction.go │ ├── direction_test.go │ ├── fill_mode.go │ ├── keyframe.go │ ├── origin.go │ ├── origin_test.go │ ├── percentage.go │ ├── positioned.go │ ├── positioned_test.go │ ├── rotate.go │ ├── rotate_test.go │ ├── rounding.go │ ├── scale.go │ ├── scale_test.go │ ├── transform.go │ ├── transform_test.go │ ├── transformation.go │ ├── transformation_test.go │ ├── translate.go │ ├── translate_test.go │ ├── util.go │ ├── util_test.go │ └── vector.go ├── box.go ├── box_test.go ├── circle.go ├── colors.go ├── colors_test.go ├── column.go ├── column_test.go ├── fonts.go ├── fonts_raw.go ├── gen │ └── embedfonts.go ├── image.go ├── image_test.go ├── image_webp.go ├── image_webp_js.go ├── marquee.go ├── marquee_test.go ├── padding.go ├── padding_test.go ├── paths.go ├── pie_chart.go ├── plot.go ├── plot_test.go ├── root.go ├── row.go ├── row_test.go ├── sequence.go ├── sequence_test.go ├── stack.go ├── starfield.go ├── testutil.go ├── text.go ├── text_test.go ├── tracer.go ├── tracer_test.go ├── vector.go ├── vector_test.go ├── widget.go ├── wrappedtext.go └── wrappedtext_test.go ├── renovate.json ├── runtime ├── applet.go ├── applet_test.go ├── cache.go ├── cache_test.go ├── config.go ├── gen │ ├── attr │ │ ├── bool.tmpl │ │ ├── child.tmpl │ │ ├── children.tmpl │ │ ├── color.tmpl │ │ ├── colors.tmpl │ │ ├── curve.tmpl │ │ ├── datapoint.tmpl │ │ ├── dataseries.tmpl │ │ ├── direction.tmpl │ │ ├── fill_mode.tmpl │ │ ├── float.tmpl │ │ ├── insets.tmpl │ │ ├── int.tmpl │ │ ├── int32.tmpl │ │ ├── keyframes.tmpl │ │ ├── origin.tmpl │ │ ├── pct.tmpl │ │ ├── percentage.tmpl │ │ ├── rounding.tmpl │ │ ├── string.tmpl │ │ ├── transforms.tmpl │ │ └── weights.tmpl │ ├── docs │ │ ├── animation.tmpl │ │ └── render.tmpl │ ├── header │ │ ├── animation.tmpl │ │ └── render.tmpl │ ├── main.go │ └── type.tmpl ├── generate.go ├── httpcache.go ├── httpcache_test.go ├── modules │ ├── animation_runtime │ │ ├── curve.go │ │ ├── generated.go │ │ └── percentage.go │ ├── file │ │ └── file.go │ ├── hmac │ │ ├── hmac.go │ │ └── hmac_test.go │ ├── humanize │ │ ├── humanize.go │ │ └── humanize_test.go │ ├── qrcode │ │ ├── qrcode.go │ │ └── qrcode_test.go │ ├── random │ │ ├── random.go │ │ └── random_test.go │ ├── render_runtime │ │ ├── data.go │ │ └── generated.go │ ├── starlarkhttp │ │ ├── starlarkhttp.go │ │ ├── starlarkhttp_test.go │ │ └── testdata │ │ │ └── test.star │ ├── sunrise │ │ ├── sunrise.go │ │ └── sunrise_test.go │ └── xpath │ │ ├── xpath.go │ │ └── xpath_test.go ├── render_test.go ├── secret.go ├── secret_test.go ├── test.star └── testdata │ └── httpcache.star ├── schema ├── color.go ├── color_test.go ├── datetime.go ├── datetime_test.go ├── dropdown.go ├── dropdown_test.go ├── generated.go ├── generated_test.go ├── handler.go ├── handler_test.go ├── location.go ├── location_test.go ├── locationbased.go ├── locationbased_test.go ├── module.go ├── module_test.go ├── notification.go ├── notification_test.go ├── oauth2.go ├── oauth2_test.go ├── option.go ├── option_test.go ├── photoselect.go ├── photoselect_test.go ├── schema.go ├── schema_test.go ├── sound.go ├── sound_test.go ├── text.go ├── text_test.go ├── toggle.go ├── toggle_test.go ├── typeahead.go └── typeahead_test.go ├── scripts ├── build-release.sh ├── fetch-deps.sh ├── release-linux.sh ├── release-macos.sh ├── release-windows.sh ├── set-executable.sh ├── set-libwebp-version.sh ├── setup-linux.sh └── setup-macos.sh ├── server ├── browser │ ├── browser.go │ ├── favicon.png │ ├── preview-mask.png │ ├── preview.html │ ├── push.go │ └── serve.go ├── fanout │ ├── client.go │ ├── event.go │ └── fanout.go ├── loader │ ├── loader.go │ └── script.go ├── server.go └── watcher.go ├── src ├── Main.jsx ├── favicon.png ├── features │ ├── appbar │ │ ├── AppBar.jsx │ │ ├── logo.svg │ │ └── styles.css │ ├── config │ │ ├── ConfigManager.jsx │ │ ├── ParamSetter.jsx │ │ ├── actions.js │ │ ├── configSlice.js │ │ └── paramSlice.js │ ├── controls │ │ └── Controls.jsx │ ├── errors │ │ ├── ErrorManager.jsx │ │ ├── ErrorSnackbar.jsx │ │ ├── errorSlice.js │ │ └── styles.css │ ├── handlers │ │ ├── actions.js │ │ └── handlerSlice.js │ ├── preview │ │ ├── Preview.jsx │ │ ├── actions.js │ │ ├── mask.png │ │ ├── previewSlice.js │ │ └── styles.css │ ├── schema │ │ ├── Field.jsx │ │ ├── FieldDetails.jsx │ │ ├── FieldIcon.jsx │ │ ├── Schema.jsx │ │ ├── actions.js │ │ ├── fields │ │ │ ├── Color.jsx │ │ │ ├── DateTime.jsx │ │ │ ├── Dropdown.jsx │ │ │ ├── Generated.jsx │ │ │ ├── TextInput.jsx │ │ │ ├── Toggle.jsx │ │ │ ├── Typeahead.jsx │ │ │ ├── location │ │ │ │ ├── InputSlider.jsx │ │ │ │ ├── LocationBased.jsx │ │ │ │ └── LocationForm.jsx │ │ │ ├── oauth2 │ │ │ │ ├── OAuth2.jsx │ │ │ │ └── OAuth2Handler.jsx │ │ │ └── photoselect │ │ │ │ ├── PhotoSelect.jsx │ │ │ │ ├── RawPhotoSelect.jsx │ │ │ │ ├── cropImage.js │ │ │ │ └── styles.css │ │ ├── schemaSlice.js │ │ └── styles.css │ ├── theme │ │ ├── DevToolsTheme.jsx │ │ ├── colors.js │ │ ├── styles.css │ │ └── theme.js │ └── watcher │ │ ├── WatcherManager.jsx │ │ └── watcher.js ├── index.html ├── index.js └── store.js ├── starlarkutil ├── context.go ├── context_test.go └── onexit.go ├── tools ├── generator │ ├── generator.go │ └── templates │ │ └── source.star.tmpl ├── repo │ ├── repo.go │ └── repo_test.go └── singlefilefs.go ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-proposal-class-properties" 7 | ] 8 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.star linguist-language=starlark 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | .DS_Store 3 | 4 | # Rendered Apps 5 | examples/**/*.webp 6 | examples/**/*.gif 7 | 8 | # Pixlet Binary 9 | pixlet 10 | pixlet.exe 11 | pixlet.wasm 12 | 13 | # Releases 14 | build/ 15 | out/ 16 | dist/ 17 | 18 | # Dependency directories 19 | node_modules 20 | src/go 21 | 22 | # build files from monorepo 23 | BUILD.bazel 24 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | dist: out 2 | 3 | before: 4 | hooks: 5 | - scripts/set-executable.sh 6 | 7 | builds: 8 | - builder: prebuilt 9 | binary: pixlet 10 | 11 | goarch: 12 | - amd64 13 | - arm64 14 | 15 | goos: 16 | - darwin 17 | - linux 18 | - windows 19 | 20 | goarm: 21 | - 7 22 | 23 | goamd64: 24 | - v1 25 | 26 | ignore: 27 | - goos: windows 28 | goarch: arm64 29 | 30 | prebuilt: 31 | path: build/{{ .Os }}_{{ .Arch }}/pixlet{{ .Ext }} 32 | 33 | checksum: 34 | name_template: "checksums.txt" 35 | 36 | changelog: 37 | sort: asc 38 | 39 | brews: 40 | - tap: 41 | owner: tidbyt 42 | name: homebrew-tidbyt 43 | 44 | commit_author: 45 | name: tidbyt-bot 46 | email: bot@tidbyt.com 47 | 48 | homepage: https://github.com/tidbyt/pixlet 49 | 50 | description: App runtime and UX toolkit for pixel-based apps. 51 | 52 | dependencies: 53 | - webp 54 | 55 | test: | 56 | system "#{bin}/pixlet --version" 57 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GIT_COMMIT = $(shell git rev-list -1 HEAD) 2 | 3 | ifeq ($(OS),Windows_NT) 4 | BINARY = pixlet.exe 5 | LDFLAGS = -ldflags="-s -extldflags=-static -X 'tidbyt.dev/pixlet/cmd.Version=$(GIT_COMMIT)'" 6 | TAGS = -tags timetzdata 7 | else 8 | BINARY = pixlet 9 | LDFLAGS = -ldflags="-X 'tidbyt.dev/pixlet/cmd.Version=$(GIT_COMMIT)'" 10 | TAGS = 11 | endif 12 | 13 | all: build 14 | 15 | test: 16 | go test $(TAGS) -v -cover ./... 17 | 18 | clean: 19 | rm -f $(BINARY) 20 | rm -rf ./build 21 | rm -rf ./out 22 | 23 | bench: 24 | go test -benchmem -benchtime=20s -bench BenchmarkRunAndRender tidbyt.dev/pixlet/encode 25 | 26 | build: 27 | go build $(LDFLAGS) $(TAGS) -o $(BINARY) tidbyt.dev/pixlet 28 | 29 | embedfonts: 30 | go run render/gen/embedfonts.go 31 | gofmt -s -w ./ 32 | 33 | widgets: 34 | go run runtime/gen/main.go 35 | gofmt -s -w ./ 36 | 37 | release-macos: clean 38 | ./scripts/release-macos.sh 39 | 40 | release-linux: clean 41 | ./scripts/release-linux.sh 42 | 43 | release-windows: clean 44 | ./scripts/release-windows.sh 45 | 46 | install-buildifier: 47 | go install github.com/bazelbuild/buildtools/buildifier@latest 48 | 49 | lint: 50 | @ buildifier --version >/dev/null 2>&1 || $(MAKE) install-buildifier 51 | buildifier -r ./ 52 | 53 | format: lint -------------------------------------------------------------------------------- /bundle/testdata/bundle.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/bundle/testdata/bundle.tar.gz -------------------------------------------------------------------------------- /bundle/testdata/excess-files.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/bundle/testdata/excess-files.tar.gz -------------------------------------------------------------------------------- /bundle/testdata/testapp/a_subdirectory/hi.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/bundle/testdata/testapp/a_subdirectory/hi.jpg -------------------------------------------------------------------------------- /bundle/testdata/testapp/manifest.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | id: test-app 3 | name: Test App 4 | summary: For Testing 5 | desc: It's an app for testing. 6 | author: Test Dev 7 | -------------------------------------------------------------------------------- /bundle/testdata/testapp/test.txt: -------------------------------------------------------------------------------- 1 | Hello there! Did you really expect to find something interesting in a test file? -------------------------------------------------------------------------------- /bundle/testdata/testapp/test_app.star: -------------------------------------------------------------------------------- 1 | """ 2 | Applet: Test App 3 | Summary: For Testing 4 | Description: It's an app for testing. 5 | Author: Test Dev 6 | """ 7 | 8 | load("a_subdirectory/hi.jpg", hi_jpeg = "file") 9 | load("render.star", "render") 10 | load("schema.star", "schema") 11 | load("test.txt", test_txt = "file") 12 | 13 | DEFAULT_WHO = "world" 14 | 15 | TEST_TXT_CONTENT = test_txt.readall() 16 | 17 | HI_JPEG_BYTES = hi_jpeg.readall("rb") 18 | 19 | def main(config): 20 | who = config.str("who", DEFAULT_WHO) 21 | message = "Hello, {}!".format(who) 22 | return render.Root( 23 | child = render.Text(message), 24 | ) 25 | 26 | def get_schema(): 27 | return schema.Schema( 28 | version = "1", 29 | fields = [ 30 | schema.Text( 31 | id = "who", 32 | name = "Who?", 33 | desc = "Who to say hello to.", 34 | icon = "user", 35 | ), 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /bundle/testdata/testapp/unused.txt: -------------------------------------------------------------------------------- 1 | this file is not used in the app -------------------------------------------------------------------------------- /cmd/community/community.go: -------------------------------------------------------------------------------- 1 | package community 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func init() { 8 | CommunityCmd.AddCommand(ListIconsCmd) 9 | CommunityCmd.AddCommand(LoadAppCmd) 10 | CommunityCmd.AddCommand(ValidateIconsCmd) 11 | CommunityCmd.AddCommand(ValidateManifestCmd) 12 | } 13 | 14 | var CommunityCmd = &cobra.Command{ 15 | Use: "community", 16 | Short: "Utilities to manage the community repo", 17 | Long: `The community subcommand provides a set of utilities for managing the 18 | community repo. This subcommand should be considered slightly unstable in that 19 | we may determine a utility here should move to a more generalizable tool.`, 20 | } 21 | -------------------------------------------------------------------------------- /cmd/community/createmanifest.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package community 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/spf13/cobra" 11 | "tidbyt.dev/pixlet/manifest" 12 | ) 13 | 14 | var CreateManifestCmd = &cobra.Command{ 15 | Use: "create-manifest ", 16 | Short: "Creates an app manifest from a prompt", 17 | Example: ` pixlet community create-manifest manifest.yaml`, 18 | Long: `This command creates an app manifest by asking a series of prompts.`, 19 | Args: cobra.ExactArgs(1), 20 | RunE: CreateManifest, 21 | } 22 | 23 | func init() { 24 | CommunityCmd.AddCommand(CreateManifestCmd) 25 | } 26 | 27 | func CreateManifest(cmd *cobra.Command, args []string) error { 28 | fileName := filepath.Base(args[0]) 29 | if fileName != manifest.ManifestFileName { 30 | return fmt.Errorf("supplied manifest must be named %s", manifest.ManifestFileName) 31 | } 32 | 33 | f, err := os.Create(args[0]) 34 | if err != nil { 35 | return fmt.Errorf("couldn't open manifest: %w", err) 36 | } 37 | defer f.Close() 38 | 39 | m, err := ManifestPrompt() 40 | if err != nil { 41 | return fmt.Errorf("failed prompt: %w", err) 42 | } 43 | 44 | err = m.WriteManifest(f) 45 | if err != nil { 46 | return fmt.Errorf("couldn't write manifest: %w", err) 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/community/listicons.go: -------------------------------------------------------------------------------- 1 | package community 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/spf13/cobra" 8 | "tidbyt.dev/pixlet/icons" 9 | ) 10 | 11 | var ListIconsCmd = &cobra.Command{ 12 | Use: "list-icons", 13 | Short: "List icons that are available in our mobile app.", 14 | Example: ` pixlet community list-icons`, 15 | Long: `This command lists all in your icons that are supported by our mobile app.`, 16 | RunE: listIcons, 17 | } 18 | 19 | func listIcons(cmd *cobra.Command, args []string) error { 20 | iconSet := []string{} 21 | for icon := range icons.IconsMap { 22 | iconSet = append(iconSet, icon) 23 | } 24 | 25 | sort.Strings(iconSet) 26 | for _, icon := range iconSet { 27 | fmt.Println(icon) 28 | } 29 | 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /cmd/community/loadapp.go: -------------------------------------------------------------------------------- 1 | package community 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | "tidbyt.dev/pixlet/runtime" 12 | "tidbyt.dev/pixlet/tools" 13 | ) 14 | 15 | var LoadAppCmd = &cobra.Command{ 16 | Use: "load-app ", 17 | Short: "Validates an app can be successfully loaded in our runtime.", 18 | Example: `pixlet community load-app examples/clock`, 19 | Long: `This command ensures an app can be loaded into our runtime successfully.`, 20 | Args: cobra.ExactArgs(1), 21 | RunE: LoadApp, 22 | } 23 | 24 | func LoadApp(cmd *cobra.Command, args []string) error { 25 | path := args[0] 26 | 27 | // check if path exists, and whether it is a directory or a file 28 | info, err := os.Stat(path) 29 | if err != nil { 30 | return fmt.Errorf("failed to stat %s: %w", path, err) 31 | } 32 | 33 | var fs fs.FS 34 | if info.IsDir() { 35 | fs = os.DirFS(path) 36 | } else { 37 | if !strings.HasSuffix(path, ".star") { 38 | return fmt.Errorf("script file must have suffix .star: %s", path) 39 | } 40 | 41 | fs = tools.NewSingleFileFS(path) 42 | } 43 | 44 | cache := runtime.NewInMemoryCache() 45 | runtime.InitHTTP(cache) 46 | runtime.InitCache(cache) 47 | 48 | if _, err := runtime.NewAppletFromFS(filepath.Base(path), fs, runtime.WithPrintDisabled()); err != nil { 49 | return fmt.Errorf("failed to load applet: %w", err) 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /cmd/community/manifestprompt.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package community 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/manifoldco/promptui" 9 | "tidbyt.dev/pixlet/manifest" 10 | ) 11 | 12 | func ManifestPrompt() (*manifest.Manifest, error) { 13 | // Get the name of the app. 14 | namePrompt := promptui.Prompt{ 15 | Label: "Name (what do you want to call your app?)", 16 | Validate: manifest.ValidateName, 17 | } 18 | name, err := namePrompt.Run() 19 | if err != nil { 20 | return nil, fmt.Errorf("app creation failed %w", err) 21 | } 22 | 23 | // Get the summary of the app. 24 | summaryPrompt := promptui.Prompt{ 25 | Label: "Summary (what's the short and sweet of what this app does?)", 26 | Validate: manifest.ValidateSummary, 27 | } 28 | summary, err := summaryPrompt.Run() 29 | if err != nil { 30 | return nil, fmt.Errorf("app creation failed %w", err) 31 | } 32 | 33 | // Get the description of the app. 34 | descPrompt := promptui.Prompt{ 35 | Label: "Description (what's the long form of what this app does?)", 36 | Validate: manifest.ValidateDesc, 37 | } 38 | desc, err := descPrompt.Run() 39 | if err != nil { 40 | return nil, fmt.Errorf("app creation failed %w", err) 41 | } 42 | 43 | // Get the author of the app. 44 | authorPrompt := promptui.Prompt{ 45 | Label: "Author (your name or your Github handle)", 46 | Validate: manifest.ValidateAuthor, 47 | } 48 | author, err := authorPrompt.Run() 49 | if err != nil { 50 | return nil, fmt.Errorf("app creation failed %w", err) 51 | } 52 | 53 | return &manifest.Manifest{ 54 | ID: manifest.GenerateID(name), 55 | Name: name, 56 | Summary: summary, 57 | Desc: desc, 58 | Author: author, 59 | }, nil 60 | } 61 | -------------------------------------------------------------------------------- /cmd/community/validatemanifest.go: -------------------------------------------------------------------------------- 1 | package community 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/spf13/cobra" 9 | "tidbyt.dev/pixlet/manifest" 10 | ) 11 | 12 | var ValidateManifestAppFileName string 13 | 14 | func init() { 15 | } 16 | 17 | var ValidateManifestCmd = &cobra.Command{ 18 | Use: "validate-manifest ", 19 | Short: "Validates an app manifest is ready for publishing", 20 | Example: ` pixlet community validate-manifest manifest.yaml`, 21 | Long: `This command determines if your app manifest is configured properly by 22 | validating the contents of each field.`, 23 | Args: cobra.ExactArgs(1), 24 | RunE: ValidateManifest, 25 | } 26 | 27 | func ValidateManifest(cmd *cobra.Command, args []string) error { 28 | fileName := filepath.Base(args[0]) 29 | if fileName != manifest.ManifestFileName { 30 | return fmt.Errorf("supplied manifest must be named %s", manifest.ManifestFileName) 31 | } 32 | 33 | f, err := os.Open(args[0]) 34 | if err != nil { 35 | return fmt.Errorf("couldn't open manifest: %w", err) 36 | } 37 | defer f.Close() 38 | 39 | m, err := manifest.LoadManifest(f) 40 | if err != nil { 41 | return fmt.Errorf("couldn't load manifest: %w", err) 42 | } 43 | 44 | err = m.Validate() 45 | if err != nil { 46 | return fmt.Errorf("couldn't validate manifest: %w", err) 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/spf13/viper" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | const ( 14 | OAuthCallbackAddr = "localhost:8085" 15 | ) 16 | 17 | var ( 18 | PrivateConfig = viper.New() 19 | 20 | OAuthConf = &oauth2.Config{ 21 | ClientID: "d8ae7ea0-4a1a-46b0-b556-6d742687223a", 22 | Scopes: []string{"device", "offline_access", "app-admin"}, 23 | Endpoint: oauth2.Endpoint{ 24 | AuthURL: "https://login.tidbyt.com/oauth2/auth", 25 | TokenURL: "https://login.tidbyt.com/oauth2/token", 26 | }, 27 | RedirectURL: fmt.Sprintf("http://%s", OAuthCallbackAddr), 28 | } 29 | ) 30 | 31 | func init() { 32 | if ucd, err := os.UserConfigDir(); err == nil { 33 | configPath := filepath.Join(ucd, "tidbyt") 34 | 35 | if err := os.MkdirAll(configPath, os.ModePerm); err == nil { 36 | PrivateConfig.AddConfigPath(configPath) 37 | } 38 | } 39 | 40 | PrivateConfig.SetConfigName("private") 41 | PrivateConfig.SetConfigType("yaml") 42 | PrivateConfig.SetConfigPermissions(0600) 43 | 44 | PrivateConfig.SafeWriteConfig() 45 | PrivateConfig.ReadInConfig() 46 | } 47 | 48 | func OAuthTokenFromConfig(ctx context.Context) string { 49 | if !PrivateConfig.IsSet("token") { 50 | return "" 51 | } 52 | 53 | var tok oauth2.Token 54 | if err := PrivateConfig.UnmarshalKey("token", &tok); err != nil { 55 | fmt.Println("unmarshaling API token from config:", err) 56 | os.Exit(1) 57 | } 58 | 59 | if !tok.Valid() { 60 | // probably expired, try to refresh 61 | ts := OAuthConf.TokenSource(ctx, &tok) 62 | refreshed, err := ts.Token() 63 | if err != nil { 64 | fmt.Println("refreshing API token:", err) 65 | os.Exit(1) 66 | } 67 | 68 | tok = *refreshed 69 | PrivateConfig.Set("token", tok) 70 | PrivateConfig.WriteConfig() 71 | } 72 | 73 | return tok.AccessToken 74 | } 75 | -------------------------------------------------------------------------------- /cmd/delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "tidbyt.dev/pixlet/cmd/config" 11 | ) 12 | 13 | const ( 14 | TidbytAPIDelete = "https://api.tidbyt.com/v0/devices/%s/installations/%s" 15 | ) 16 | 17 | func init() { 18 | DeleteCmd.Flags().StringVarP(&apiToken, "api-token", "t", "", "Tidbyt API token") 19 | } 20 | 21 | var DeleteCmd = &cobra.Command{ 22 | Use: "delete [device ID] [installation ID]", 23 | Short: "Delete a pixlet script from a Tidbyt", 24 | Args: cobra.MinimumNArgs(2), 25 | RunE: delete, 26 | } 27 | 28 | func delete(cmd *cobra.Command, args []string) error { 29 | deviceID := args[0] 30 | installationID := args[1] 31 | 32 | if apiToken == "" { 33 | apiToken = os.Getenv(APITokenEnv) 34 | } 35 | 36 | if apiToken == "" { 37 | apiToken = config.OAuthTokenFromConfig(cmd.Context()) 38 | } 39 | 40 | if apiToken == "" { 41 | return fmt.Errorf("blank Tidbyt API token (use `pixlet login`, set $%s or pass with --api-token)", APITokenEnv) 42 | } 43 | 44 | client := &http.Client{} 45 | req, err := http.NewRequest( 46 | "DELETE", 47 | fmt.Sprintf(TidbytAPIDelete, deviceID, installationID), 48 | nil, 49 | ) 50 | if err != nil { 51 | return fmt.Errorf("creating DELETE request: %w", err) 52 | } 53 | 54 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiToken)) 55 | 56 | resp, err := client.Do(req) 57 | if err != nil { 58 | return fmt.Errorf("deleting via API: %w", err) 59 | } 60 | 61 | if resp.StatusCode != 200 { 62 | fmt.Printf("Tidbyt API returned status %s\n", resp.Status) 63 | body, _ := ioutil.ReadAll(resp.Body) 64 | fmt.Println(string(body)) 65 | return fmt.Errorf("Tidbyt API returned status: %s", resp.Status) 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /cmd/devices.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | "tidbyt.dev/pixlet/cmd/config" 12 | ) 13 | 14 | const ( 15 | TidbytAPIListDevices = "https://api.tidbyt.com/v0/devices" 16 | ) 17 | 18 | var DevicesCmd = &cobra.Command{ 19 | Use: "devices", 20 | Short: "List devices in your Tidbyt account", 21 | Run: devices, 22 | } 23 | 24 | func devices(cmd *cobra.Command, args []string) { 25 | apiToken = config.OAuthTokenFromConfig(cmd.Context()) 26 | if apiToken == "" { 27 | fmt.Println("login with `pixlet login`") 28 | os.Exit(1) 29 | } 30 | 31 | client := &http.Client{} 32 | req, err := http.NewRequest("GET", TidbytAPIListDevices, nil) 33 | if err != nil { 34 | fmt.Printf("creating GET request: %v\n", err) 35 | os.Exit(1) 36 | } 37 | 38 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiToken)) 39 | 40 | resp, err := client.Do(req) 41 | if err != nil { 42 | fmt.Printf("listing devices from API: %v\n", err) 43 | os.Exit(1) 44 | } 45 | 46 | if resp.StatusCode != 200 { 47 | fmt.Printf("Tidbyt API returned status %s\n", resp.Status) 48 | body, _ := ioutil.ReadAll(resp.Body) 49 | fmt.Println(string(body)) 50 | os.Exit(1) 51 | } 52 | 53 | body := struct { 54 | Devices []struct { 55 | ID string `json:"id"` 56 | DisplayName string `json:"displayName"` 57 | } `json:"devices"` 58 | }{} 59 | if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 60 | fmt.Println("decoding API response:", err) 61 | os.Exit(1) 62 | } 63 | 64 | for _, d := range body.Devices { 65 | fmt.Printf("%s (%s)\n", d.ID, d.DisplayName) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /cmd/encrypt.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/spf13/cobra" 8 | "go.starlark.net/starlark" 9 | 10 | "tidbyt.dev/pixlet/runtime" 11 | ) 12 | 13 | const PublicKeysetJSON = `{ 14 | "primaryKeyId": 1589560679, 15 | "key": [ 16 | { 17 | "keyData": { 18 | "typeUrl": "type.googleapis.com/google.crypto.tink.EciesAeadHkdfPublicKey", 19 | "value": "ElwKBAgCEAMSUhJQCjh0eXBlLmdvb2dsZWFwaXMuY29tL2dvb2dsZS5jcnlwdG8udGluay5BZXNDdHJIbWFjQWVhZEtleRISCgYKAggQEBASCAoECAMQEBAgGAEYARogLGtas20og5yP8/g9mCNLNCWTDeLUdcHH7o9fbzouOQoiIBIth4hdVF5A2sztwfW+hNoZ0ht/HNH3dDTEBPW3GXA2", 20 | "keyMaterialType": "ASYMMETRIC_PUBLIC" 21 | }, 22 | "status": "ENABLED", 23 | "keyId": 1589560679, 24 | "outputPrefixType": "TINK" 25 | } 26 | ] 27 | }` 28 | 29 | var EncryptCmd = &cobra.Command{ 30 | Use: "encrypt [app ID] [secret value]...", 31 | Short: "Encrypt a secret for use in the Tidbyt community repo", 32 | Example: "encrypt weather my-top-secretweather-api-key-123456", 33 | Args: cobra.MinimumNArgs(2), 34 | Run: encrypt, 35 | } 36 | 37 | func encrypt(cmd *cobra.Command, args []string) { 38 | sek := &runtime.SecretEncryptionKey{ 39 | PublicKeysetJSON: []byte(PublicKeysetJSON), 40 | } 41 | 42 | appID := args[0] 43 | encrypted := make([]string, len(args)-1) 44 | 45 | for i, val := range args[1:] { 46 | var err error 47 | encrypted[i], err = sek.Encrypt(appID, val) 48 | if err != nil { 49 | log.Fatalf("encrypting value: %v", err) 50 | } 51 | } 52 | 53 | for _, val := range encrypted { 54 | fmt.Println(starlark.String(val).String()) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /cmd/private/bundle.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "tidbyt.dev/pixlet/bundle" 9 | ) 10 | 11 | var bundleOutput string 12 | 13 | func init() { 14 | BundleCmd.Flags().StringVarP(&bundleOutput, "output", "o", "./", "output directory for the bundle") 15 | } 16 | 17 | var BundleCmd = &cobra.Command{ 18 | Use: "bundle", 19 | Short: "Creates a new app bundle", 20 | Example: ` pixlet bundle ./my-app`, 21 | Long: `This command will create a new app bundle from an app directory. The directory 22 | should contain an app manifest and source file. The output of this command will 23 | be a gzip compressed tar file that can be uploaded to Tidbyt for deployment.`, 24 | Args: cobra.ExactArgs(1), 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | bundleInput := args[0] 27 | info, err := os.Stat(bundleInput) 28 | if err != nil { 29 | return fmt.Errorf("input directory invalid: %w", err) 30 | } 31 | 32 | if !info.IsDir() { 33 | return fmt.Errorf("input must be a directory") 34 | } 35 | 36 | info, err = os.Stat(bundleOutput) 37 | if err != nil { 38 | return fmt.Errorf("output directory invalid: %w", err) 39 | } 40 | 41 | if !info.IsDir() { 42 | return fmt.Errorf("output must be a directory") 43 | } 44 | 45 | ab, err := bundle.FromDir(bundleInput) 46 | if err != nil { 47 | return fmt.Errorf("could not init bundle: %w", err) 48 | } 49 | 50 | return ab.WriteBundleToPath(bundleOutput) 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /cmd/private/delete.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | "tidbyt.dev/pixlet/cmd/config" 11 | ) 12 | 13 | var deleteURL string 14 | var deleteAppID string 15 | 16 | func init() { 17 | DeleteCmd.Flags().StringVarP(&deleteURL, "url", "u", "https://api.tidbyt.com", "base URL of Tidbyt API") 18 | DeleteCmd.Flags().StringVarP(&deleteAppID, "app", "a", "", "ID of app to delete") 19 | DeleteCmd.MarkFlagRequired("app") 20 | } 21 | 22 | var DeleteCmd = &cobra.Command{ 23 | Use: "delete", 24 | Short: "Deletes a private app", 25 | Long: `Deletes a private app, and attempt to uninstall it from owner's devices.`, 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | apiToken := config.OAuthTokenFromConfig(cmd.Context()) 28 | if apiToken == "" { 29 | return fmt.Errorf("login with `pixlet login` or use `pixlet set-auth` to configure auth") 30 | } 31 | 32 | requestURL := fmt.Sprintf("%s/v0/apps/%s", deleteURL, deleteAppID) 33 | req, err := http.NewRequest("DELETE", requestURL, nil) 34 | if err != nil { 35 | return fmt.Errorf("could not create http request: %w", err) 36 | } 37 | 38 | req.Header.Set("Content-Type", "application/json") 39 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiToken)) 40 | 41 | client := http.Client{ 42 | Timeout: 10 * time.Second, 43 | } 44 | 45 | resp, err := client.Do(req) 46 | if err != nil { 47 | return fmt.Errorf("could not make HTTP request to %s: %w", requestURL, err) 48 | } 49 | defer resp.Body.Close() 50 | 51 | body, err := io.ReadAll(resp.Body) 52 | if err != nil { 53 | return fmt.Errorf("could not read response body: %w", err) 54 | } 55 | 56 | if resp.StatusCode != 200 { 57 | return fmt.Errorf("request returned status %d with message: %s", resp.StatusCode, body) 58 | } 59 | 60 | return nil 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /cmd/private/private.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func init() { 8 | PrivateCmd.AddCommand(CreateCmd) 9 | PrivateCmd.AddCommand(BundleCmd) 10 | PrivateCmd.AddCommand(UploadCmd) 11 | PrivateCmd.AddCommand(DeployCmd) 12 | PrivateCmd.AddCommand(DeleteCmd) 13 | PrivateCmd.AddCommand(ListCmd) 14 | PrivateCmd.AddCommand(LogsCmd) 15 | } 16 | 17 | var PrivateCmd = &cobra.Command{ 18 | Use: "private", 19 | Short: "Utilities to manage private apps", 20 | Long: `The private subcommand provides a set of utilities for managing 21 | private apps. Requires Tidbyt Plus or Tidbyt for Teams.`, 22 | } 23 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "tidbyt.dev/pixlet/server" 9 | ) 10 | 11 | var ( 12 | host string 13 | port int 14 | watch bool 15 | serveGif bool 16 | ) 17 | 18 | func init() { 19 | ServeCmd.Flags().StringVarP(&host, "host", "i", "127.0.0.1", "Host interface for serving rendered images") 20 | ServeCmd.Flags().IntVarP(&port, "port", "p", 8080, "Port for serving rendered images") 21 | ServeCmd.Flags().BoolVarP(&watch, "watch", "w", true, "Reload scripts on change. Does not recurse sub-directories.") 22 | ServeCmd.Flags().IntVarP(&maxDuration, "max_duration", "d", 15000, "Maximum allowed animation duration (ms)") 23 | ServeCmd.Flags().IntVarP(&timeout, "timeout", "", 30000, "Timeout for execution (ms)") 24 | ServeCmd.Flags().BoolVarP(&serveGif, "gif", "", false, "Generate GIF instead of WebP") 25 | } 26 | 27 | var ServeCmd = &cobra.Command{ 28 | Use: "serve [path]", 29 | Short: "Serve a Pixlet app in a web server", 30 | Args: cobra.ExactArgs(1), 31 | RunE: serve, 32 | Long: `Serve a Pixlet app in a web server. 33 | 34 | The path argument should be the path to the Pixlet program to run. The 35 | program can be a single file with the .star extension, or a directory 36 | containing multiple Starlark files and resources.`, 37 | } 38 | 39 | func serve(cmd *cobra.Command, args []string) error { 40 | if watch && cmd.Flags().Changed("watch") { 41 | fmt.Printf("explicitly setting --watch is unnecessary, since it's the default\n\n") 42 | } 43 | 44 | s, err := server.NewServer(host, port, watch, args[0], maxDuration, timeout, serveGif) 45 | if err != nil { 46 | return err 47 | } 48 | return s.Run() 49 | } 50 | -------------------------------------------------------------------------------- /cmd/setauth.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "golang.org/x/oauth2" 9 | "tidbyt.dev/pixlet/cmd/config" 10 | ) 11 | 12 | var SetAuthCmd = &cobra.Command{ 13 | Use: "set-auth", 14 | Short: "Sets a custom access token in the private pixlet config.", 15 | Example: ` pixlet set-auth `, 16 | Long: `This command sets a custom access token for use in subsequent runs. Normal users 17 | should not need this - use 'pixlet login' instead.`, 18 | Args: cobra.ExactArgs(1), 19 | RunE: SetAuth, 20 | } 21 | 22 | func SetAuth(cmd *cobra.Command, args []string) error { 23 | authJSON := args[0] 24 | tok := &oauth2.Token{} 25 | err := json.Unmarshal([]byte(authJSON), tok) 26 | if err != nil { 27 | return fmt.Errorf("could not load auth JSON: %w", err) 28 | } 29 | 30 | config.PrivateConfig.Set("token", tok) 31 | if err := config.PrivateConfig.WriteConfig(); err != nil { 32 | return fmt.Errorf("could not persist auth token in config: %w", err) 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var Version string 10 | 11 | var VersionCmd = &cobra.Command{ 12 | Use: "version", 13 | Short: "Show the version of Pixlet", 14 | Run: func(cmd *cobra.Command, args []string) { 15 | fmt.Printf("Pixlet version: %s\n", Version) 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /dist/.gitignore: -------------------------------------------------------------------------------- 1 | static/* 2 | !static/keepdir -------------------------------------------------------------------------------- /dist/dist.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package dist 4 | 5 | import "embed" 6 | 7 | //go:embed static 8 | var Static embed.FS 9 | 10 | //go:embed index.html 11 | var Index []byte 12 | -------------------------------------------------------------------------------- /dist/dist_js.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package dist 4 | 5 | import "embed" 6 | 7 | // dummy values not used in wasm build 8 | var ( 9 | Static embed.FS 10 | Index []byte 11 | ) 12 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dist/static/keepdir: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/dist/static/keepdir -------------------------------------------------------------------------------- /docs/BUILD.md: -------------------------------------------------------------------------------- 1 | Building Pixlet 2 | =============== 3 | 4 | Note - if you're trying to build for windows, check out the [windows build instructions](BUILD_WINDOWS.md). 5 | 6 | Prerequisites 7 | ------------- 8 | 9 | - Having [go installed]. 10 | - Having [node installed]. 11 | - Having [libwebp installed]. 12 | 13 | Steps 14 | ----- 15 | - Clone the repository: 16 | ```console 17 | git clone https://github.com/tidbyt/pixlet 18 | ``` 19 | - Cd into the repository: 20 | ```console 21 | cd pixlet 22 | ``` 23 | - Build the frontend: 24 | ```console 25 | npm install 26 | npm run build 27 | ``` 28 | - Build the binary: 29 | ```console 30 | make build 31 | ``` 32 | - After that you will have the binary `/pixlet`, which you should copy to your path. 33 | 34 | [go installed]: https://golang.org/dl/ 35 | [node installed]: https://nodejs.org/en/download/ 36 | [libwebp installed]: https://developers.google.com/speed/webp/download 37 | -------------------------------------------------------------------------------- /docs/BUILD_WINDOWS.md: -------------------------------------------------------------------------------- 1 | Building Pixlet (on Windows) 2 | ============================ 3 | 4 | Prerequisites 5 | ------------- 6 | 7 | - Having [MSYS2 installed]. 8 | - Having [node installed]. 9 | 10 | Steps 11 | ----- 12 | - Start the [MINGW64 environment]. 13 | - Install dependencies: 14 | ```console 15 | pacman -S git 16 | pacman -S mingw-w64-x86_64-go 17 | pacman -S mingw-w64-x86_64-toolchain 18 | pacman -S mingw-w64-x86_64-libwebp 19 | ``` 20 | - Add `node` and `npm` to your path: 21 | ```console 22 | export PATH=$PATH:/c/Program\ Files/nodejs 23 | ``` 24 | - Clone the repository: 25 | ```console 26 | git clone https://github.com/tidbyt/pixlet 27 | ``` 28 | - Cd into the repository: 29 | ```console 30 | cd pixlet 31 | ``` 32 | - Build the frontend: 33 | ```console 34 | npm install 35 | npm run build 36 | ``` 37 | - Build the binary: 38 | ```console 39 | make build 40 | ``` 41 | - After that you will have the binary `/pixlet.exe`, which you should copy to your path. 42 | 43 | [node installed]: https://nodejs.org/en/download/ 44 | [MSYS2 installed]: https://www.msys2.org/#installation 45 | [MINGW64 environment]: https://www.msys2.org/docs/environments/ 46 | -------------------------------------------------------------------------------- /docs/gifs.md: -------------------------------------------------------------------------------- 1 | # Creating GIFs for Tidbyt 2 | 3 | By using Pixlet's [image widget](widgets.md), images and GIFs can easily be displayed on pixel constrained displays. 4 | 5 | However, when creating GIFs for use on Tidbyt, there are a few requirements to keep in mind for the best result. 6 | 7 | ## Design GIFs to be 64x32 pixels from the start 8 | Tidbyt's display is 64x32 pixels. If there is a GIF that’s larger than 64x32 pixels, it has to be scaled down. In practice, we’ve found that images scaled down to this resolution don’t look as crisp as when images are designed for 64x32 from the beginning. So if you’re creating GIFs for the Tidbyt, make sure they’re 64x32. 9 | 10 | ## Finished GIF is 128KB or less 11 | We’re limited by the number of bytes we can send to the Tidbyt and the Tidbyt is constrained by how many bytes it can store locally. To get around this, we limit the size of the GIF to 128 Kilobytes and if it’s larger than this after downsizing to 64x32 pixels, we drop frames until it fits the size requirements. This means if you want your GIF to look great on the Tidbyt, make sure it’s 128KB or less before adding it through the mobile app or with Pixlet. 12 | 13 | ## GIF is 15 seconds in length or loops cleanly if less than 15 seconds. 14 | The length of time the GIF loops should be around 15 seconds. The timings for applet cycles are 15, 10, 7.5, and 5 seconds depending on the setting in the mobile app. If your GIF is less than 15 seconds, ensure it loops cleanly to avoid an interrupt. -------------------------------------------------------------------------------- /docs/img/10x20.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/10x20.gif -------------------------------------------------------------------------------- /docs/img/5x8.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/5x8.gif -------------------------------------------------------------------------------- /docs/img/6x10-rounded.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/6x10-rounded.gif -------------------------------------------------------------------------------- /docs/img/6x10.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/6x10.gif -------------------------------------------------------------------------------- /docs/img/6x13.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/6x13.gif -------------------------------------------------------------------------------- /docs/img/CG-pixel-3x5-mono.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/CG-pixel-3x5-mono.gif -------------------------------------------------------------------------------- /docs/img/CG-pixel-4x5-mono.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/CG-pixel-4x5-mono.gif -------------------------------------------------------------------------------- /docs/img/Dina_r400-6.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/Dina_r400-6.gif -------------------------------------------------------------------------------- /docs/img/Typography_Line_Terms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/Typography_Line_Terms.png -------------------------------------------------------------------------------- /docs/img/clock.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/clock.gif -------------------------------------------------------------------------------- /docs/img/mobile_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/mobile_1.jpg -------------------------------------------------------------------------------- /docs/img/tb-8.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/tb-8.gif -------------------------------------------------------------------------------- /docs/img/tidbyt_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/tidbyt_1.png -------------------------------------------------------------------------------- /docs/img/tidbyt_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/tidbyt_2.jpg -------------------------------------------------------------------------------- /docs/img/tom-thumb.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/tom-thumb.gif -------------------------------------------------------------------------------- /docs/img/tutorial_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/tutorial_1.gif -------------------------------------------------------------------------------- /docs/img/tutorial_2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/tutorial_2.gif -------------------------------------------------------------------------------- /docs/img/tutorial_3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/tutorial_3.gif -------------------------------------------------------------------------------- /docs/img/tutorial_4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/tutorial_4.gif -------------------------------------------------------------------------------- /docs/img/tutorial_btcicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/tutorial_btcicon.png -------------------------------------------------------------------------------- /docs/img/widget_Animation_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Animation_0.gif -------------------------------------------------------------------------------- /docs/img/widget_Box_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Box_0.gif -------------------------------------------------------------------------------- /docs/img/widget_Circle_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Circle_0.gif -------------------------------------------------------------------------------- /docs/img/widget_Column_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Column_0.gif -------------------------------------------------------------------------------- /docs/img/widget_Column_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Column_1.gif -------------------------------------------------------------------------------- /docs/img/widget_Marquee_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Marquee_0.gif -------------------------------------------------------------------------------- /docs/img/widget_PieChart_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_PieChart_0.gif -------------------------------------------------------------------------------- /docs/img/widget_Plot_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Plot_0.gif -------------------------------------------------------------------------------- /docs/img/widget_Row_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Row_0.gif -------------------------------------------------------------------------------- /docs/img/widget_Row_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Row_1.gif -------------------------------------------------------------------------------- /docs/img/widget_Stack_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Stack_0.gif -------------------------------------------------------------------------------- /docs/img/widget_Text_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Text_0.gif -------------------------------------------------------------------------------- /docs/img/widget_Transformation_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_Transformation_0.gif -------------------------------------------------------------------------------- /docs/img/widget_WrappedText_0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/img/widget_WrappedText_0.gif -------------------------------------------------------------------------------- /docs/schema/color/color.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/color/color.gif -------------------------------------------------------------------------------- /docs/schema/color/example.star: -------------------------------------------------------------------------------- 1 | load("render.star", "render") 2 | load("schema.star", "schema") 3 | 4 | DEFAULT_COLOR = "#FF59FF" 5 | 6 | def main(config): 7 | color = config.str("color", DEFAULT_COLOR) 8 | 9 | return render.Root( 10 | child = render.Box( 11 | width = 64, 12 | height = 32, 13 | color = color, 14 | ), 15 | ) 16 | 17 | def get_schema(): 18 | return schema.Schema( 19 | version = "1", 20 | fields = [ 21 | schema.Color( 22 | id = "color", 23 | name = "Color", 24 | desc = "Color of the screen.", 25 | icon = "brush", 26 | default = DEFAULT_COLOR, 27 | palette = [ 28 | DEFAULT_COLOR, 29 | "#7AB0FF", 30 | "#BFEDC4", 31 | "#78DECC", 32 | "#DBB5FF", 33 | ], 34 | ), 35 | ], 36 | ) 37 | -------------------------------------------------------------------------------- /docs/schema/datetime/datetime.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/datetime/datetime.gif -------------------------------------------------------------------------------- /docs/schema/datetime/example.star: -------------------------------------------------------------------------------- 1 | load("render.star", "render") 2 | load("schema.star", "schema") 3 | load("time.star", "time") 4 | 5 | def main(config): 6 | user_configured = config.get("event_time", "2022-02-02T20:00:00Z") 7 | event_time = time.parse_time(user_configured).in_location("America/New_York") 8 | 9 | return render.Root( 10 | child = render.Marquee( 11 | width = 64, 12 | child = render.Text(event_time.format("3:04 PM")), 13 | ), 14 | ) 15 | 16 | def get_schema(): 17 | return schema.Schema( 18 | version = "1", 19 | fields = [ 20 | schema.DateTime( 21 | id = "event_time", 22 | name = "Event Time", 23 | desc = "The time of the event.", 24 | icon = "gear", 25 | ), 26 | ], 27 | ) 28 | -------------------------------------------------------------------------------- /docs/schema/dropdown/dropdown.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/dropdown/dropdown.gif -------------------------------------------------------------------------------- /docs/schema/dropdown/example.star: -------------------------------------------------------------------------------- 1 | load("render.star", "render") 2 | load("schema.star", "schema") 3 | 4 | def main(config): 5 | color = config.get("color", "#BFEDC4") 6 | 7 | return render.Root( 8 | child = render.Marquee( 9 | width = 64, 10 | child = render.Text("Text color", color = color), 11 | ), 12 | ) 13 | 14 | def get_schema(): 15 | options = [ 16 | schema.Option( 17 | display = "Pink", 18 | value = "#FF94FF", 19 | ), 20 | schema.Option( 21 | display = "Mustard", 22 | value = "#FFD10D", 23 | ), 24 | ] 25 | 26 | return schema.Schema( 27 | version = "1", 28 | fields = [ 29 | schema.Dropdown( 30 | id = "color", 31 | name = "Text Color", 32 | desc = "The color of text to be displayed.", 33 | icon = "brush", 34 | default = options[0].value, 35 | options = options, 36 | ), 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /docs/schema/location/example.star: -------------------------------------------------------------------------------- 1 | load("encoding/json.star", "json") 2 | load("render.star", "render") 3 | load("schema.star", "schema") 4 | 5 | DEFAULT_LOCATION = """ 6 | { 7 | "lat": "40.6781784", 8 | "lng": "-73.9441579", 9 | "description": "Brooklyn, NY, USA", 10 | "locality": "Brooklyn", 11 | "place_id": "ChIJCSF8lBZEwokRhngABHRcdoI", 12 | "timezone": "America/New_York" 13 | } 14 | """ 15 | 16 | def main(config): 17 | location = config.get("location", DEFAULT_LOCATION) 18 | loc = json.decode(location) 19 | timezone = loc["timezone"] 20 | 21 | return render.Root( 22 | child = render.Marquee( 23 | width = 64, 24 | child = render.Text("tz: %s" % timezone), 25 | ), 26 | ) 27 | 28 | def get_schema(): 29 | return schema.Schema( 30 | version = "1", 31 | fields = [ 32 | schema.Location( 33 | id = "location", 34 | name = "Location", 35 | desc = "Location for which to display time.", 36 | icon = "locationDot", 37 | ), 38 | ], 39 | ) 40 | -------------------------------------------------------------------------------- /docs/schema/location/location.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/location/location.gif -------------------------------------------------------------------------------- /docs/schema/locationbased/locationbased.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/locationbased/locationbased.gif -------------------------------------------------------------------------------- /docs/schema/oauth2/oauth2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/oauth2/oauth2.gif -------------------------------------------------------------------------------- /docs/schema/photoselect/photoselect.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/photoselect/photoselect.gif -------------------------------------------------------------------------------- /docs/schema/text/example.star: -------------------------------------------------------------------------------- 1 | load("render.star", "render") 2 | load("schema.star", "schema") 3 | 4 | def main(config): 5 | msg = config.get("msg", "Hello") 6 | return render.Root( 7 | child = render.Marquee( 8 | width = 64, 9 | child = render.Text(msg), 10 | ), 11 | ) 12 | 13 | def get_schema(): 14 | return schema.Schema( 15 | version = "1", 16 | fields = [ 17 | schema.Text( 18 | id = "msg", 19 | name = "Message", 20 | desc = "A message to display.", 21 | icon = "gear", 22 | default = "Hello", 23 | ), 24 | ], 25 | ) 26 | -------------------------------------------------------------------------------- /docs/schema/text/text.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/text/text.gif -------------------------------------------------------------------------------- /docs/schema/toggle/example.star: -------------------------------------------------------------------------------- 1 | load("render.star", "render") 2 | load("schema.star", "schema") 3 | 4 | def main(config): 5 | party_mode = config.bool("party_mode", False) 6 | if party_mode: 7 | msg = "Party mode enabled" 8 | else: 9 | msg = "Party mode disabled" 10 | 11 | return render.Root( 12 | child = render.Marquee( 13 | width = 64, 14 | child = render.Text(msg), 15 | ), 16 | ) 17 | 18 | def get_schema(): 19 | return schema.Schema( 20 | version = "1", 21 | fields = [ 22 | schema.Toggle( 23 | id = "party_mode", 24 | name = "Party Mode", 25 | desc = "A toggle to enable party mode.", 26 | icon = "gear", 27 | default = False, 28 | ), 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /docs/schema/toggle/toggle.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/toggle/toggle.gif -------------------------------------------------------------------------------- /docs/schema/typeahead/example.star: -------------------------------------------------------------------------------- 1 | load("encoding/json.star", "json") 2 | load("render.star", "render") 3 | load("schema.star", "schema") 4 | 5 | def main(config): 6 | option = config.get("search", '{"display": "Blueberry", "value": "blueberry"}') 7 | fruit = json.decode(option) 8 | 9 | return render.Root( 10 | child = render.Marquee( 11 | width = 64, 12 | child = render.Text(fruit["display"]), 13 | ), 14 | ) 15 | 16 | def search(pattern): 17 | if pattern.startswith("a"): 18 | return [ 19 | schema.Option( 20 | display = "Apple", 21 | value = "apple", 22 | ), 23 | schema.Option( 24 | display = "Apricot", 25 | value = "apricot", 26 | ), 27 | ] 28 | else: 29 | return [] 30 | 31 | def get_schema(): 32 | return schema.Schema( 33 | version = "1", 34 | fields = [ 35 | schema.Typeahead( 36 | id = "search", 37 | name = "Search", 38 | desc = "A list of items that match search.", 39 | icon = "gear", 40 | handler = search, 41 | ), 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /docs/schema/typeahead/typeahead.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/docs/schema/typeahead/typeahead.gif -------------------------------------------------------------------------------- /encode/gif.go: -------------------------------------------------------------------------------- 1 | package encode 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/draw" 9 | "image/gif" 10 | 11 | "github.com/ericpauley/go-quantize/quantize" 12 | ) 13 | 14 | // Renders a screen to GIF. Optionally pass filters for postprocessing 15 | // each individual frame. 16 | func (s *Screens) EncodeGIF(maxDuration int, filters ...ImageFilter) ([]byte, error) { 17 | images, err := s.render(filters...) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | if len(images) == 0 { 23 | return []byte{}, nil 24 | } 25 | 26 | g := &gif.GIF{} 27 | 28 | remainingDuration := maxDuration 29 | for imIdx, im := range images { 30 | imRGBA, ok := im.(*image.RGBA) 31 | if !ok { 32 | return nil, fmt.Errorf("image %d is %T, require RGBA", imIdx, im) 33 | } 34 | 35 | palette := quantize.MedianCutQuantizer{}.Quantize(make([]color.Color, 0, 256), im) 36 | imPaletted := image.NewPaletted(imRGBA.Bounds(), palette) 37 | draw.Draw(imPaletted, imRGBA.Bounds(), imRGBA, image.Point{0, 0}, draw.Src) 38 | 39 | frameDelay := int(s.delay) 40 | if maxDuration > 0 { 41 | if frameDelay > remainingDuration { 42 | frameDelay = remainingDuration 43 | } 44 | remainingDuration -= frameDelay 45 | } 46 | 47 | g.Image = append(g.Image, imPaletted) 48 | g.Delay = append(g.Delay, frameDelay/10) // in 100ths of a second 49 | 50 | if maxDuration > 0 && remainingDuration <= 0 { 51 | break 52 | } 53 | } 54 | 55 | buf := &bytes.Buffer{} 56 | err = gif.EncodeAll(buf, g) 57 | if err != nil { 58 | return nil, fmt.Errorf("encoding: %w", err) 59 | } 60 | 61 | return buf.Bytes(), nil 62 | } 63 | -------------------------------------------------------------------------------- /encode/webp.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package encode 4 | 5 | import ( 6 | "fmt" 7 | "time" 8 | 9 | "github.com/tidbyt/go-libwebp/webp" 10 | ) 11 | 12 | // Renders a screen to WebP. Optionally pass filters for 13 | // postprocessing each individual frame. 14 | func (s *Screens) EncodeWebP(maxDuration int, filters ...ImageFilter) ([]byte, error) { 15 | images, err := s.render(filters...) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | if len(images) == 0 { 21 | return []byte{}, nil 22 | } 23 | 24 | bounds := images[0].Bounds() 25 | anim, err := webp.NewAnimationEncoder( 26 | bounds.Dx(), 27 | bounds.Dy(), 28 | WebPKMin, 29 | WebPKMax, 30 | ) 31 | if err != nil { 32 | return nil, fmt.Errorf("%s: %w", "initializing encoder", err) 33 | } 34 | defer anim.Close() 35 | 36 | remainingDuration := time.Duration(maxDuration) * time.Millisecond 37 | for _, im := range images { 38 | frameDuration := time.Duration(s.delay) * time.Millisecond 39 | 40 | if maxDuration > 0 { 41 | if frameDuration > remainingDuration { 42 | frameDuration = remainingDuration 43 | } 44 | remainingDuration -= frameDuration 45 | } 46 | 47 | if err := anim.AddFrame(im, frameDuration); err != nil { 48 | return nil, fmt.Errorf("%s: %w", "adding frame", err) 49 | } 50 | 51 | if maxDuration > 0 && remainingDuration <= 0 { 52 | break 53 | } 54 | } 55 | 56 | buf, err := anim.Assemble() 57 | if err != nil { 58 | return nil, fmt.Errorf("%s: %w", "encoding animation", err) 59 | } 60 | 61 | return buf, nil 62 | } 63 | -------------------------------------------------------------------------------- /encode/webp_js.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package encode 4 | 5 | // Renders a screen to WebP. Optionally pass filters for 6 | // postprocessing each individual frame. 7 | func (s *Screens) EncodeWebP(maxDuration int, filters ...ImageFilter) ([]byte, error) { 8 | // lol you gullible sucker, you thought you could use webp in wasm? 9 | return s.EncodeGIF(maxDuration, filters...) 10 | } 11 | -------------------------------------------------------------------------------- /examples/bitcoin/bitcoin.star: -------------------------------------------------------------------------------- 1 | load("http.star", "http") 2 | load("icon.png", icon = "file") 3 | load("render.star", "render") 4 | 5 | COINDESK_PRICE_URL = "https://api.coindesk.com/v1/bpi/currentprice.json" 6 | 7 | BTC_ICON = icon.readall() 8 | 9 | def main(): 10 | rep = http.get(COINDESK_PRICE_URL, ttl_seconds = 240) 11 | if rep.status_code != 200: 12 | fail("Coindesk request failed with status %d", rep.status_code) 13 | rate = rep.json()["bpi"]["USD"]["rate_float"] 14 | 15 | return render.Root( 16 | child = render.Box( 17 | render.Row( 18 | expanded = True, 19 | main_align = "space_evenly", 20 | cross_align = "center", 21 | children = [ 22 | render.Image(src = BTC_ICON), 23 | render.Text("$%d" % rate), 24 | ], 25 | ), 26 | ), 27 | ) 28 | -------------------------------------------------------------------------------- /examples/bitcoin/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/examples/bitcoin/icon.png -------------------------------------------------------------------------------- /examples/clock/clock.star: -------------------------------------------------------------------------------- 1 | # A simple clock applet 2 | 3 | load("render.star", "render") 4 | load("time.star", "time") 5 | 6 | def main(config): 7 | timezone = config.get("timezone") or "America/New_York" 8 | now = time.now().in_location(timezone) 9 | 10 | return render.Root( 11 | delay = 500, 12 | child = render.Box( 13 | child = render.Animation( 14 | children = [ 15 | render.Text( 16 | content = now.format("3:04 PM"), 17 | font = "6x13", 18 | ), 19 | render.Text( 20 | content = now.format("3 04 PM"), 21 | font = "6x13", 22 | ), 23 | ], 24 | ), 25 | ), 26 | ) 27 | -------------------------------------------------------------------------------- /examples/font-preview/font-preview.star: -------------------------------------------------------------------------------- 1 | load("render.star", "render") 2 | 3 | def main(config): 4 | font = config.get("font", "tb-8") 5 | print("Using font: '{}'".format(font)) 6 | return render.Root( 7 | child = render.Column( 8 | children = [ 9 | render.Box( 10 | width = 64, 11 | height = 1, 12 | color = "#78DECC", 13 | ), 14 | render.Marquee( 15 | width = 64, 16 | child = render.Text("The quick brown fox jumps over the lazy dog", font = font), 17 | ), 18 | render.Box( 19 | width = 64, 20 | height = 1, 21 | color = "#78DECC", 22 | ), 23 | render.Marquee( 24 | width = 64, 25 | child = render.Text("THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG", font = font), 26 | ), 27 | render.Box( 28 | width = 64, 29 | height = 1, 30 | color = "#78DECC", 31 | ), 32 | render.Marquee( 33 | width = 64, 34 | child = render.Text("!@#$%^&*()_+:?><~`", font = font), 35 | ), 36 | render.Box( 37 | width = 64, 38 | height = 1, 39 | color = "#78DECC", 40 | ), 41 | ], 42 | ), 43 | ) 44 | -------------------------------------------------------------------------------- /examples/hello_world/hello_world.star: -------------------------------------------------------------------------------- 1 | load("render.star", "render") 2 | 3 | def main(): 4 | return render.Root( 5 | child = render.Text("Hello, World!"), 6 | ) 7 | -------------------------------------------------------------------------------- /examples/humanize/humanize.star: -------------------------------------------------------------------------------- 1 | load("humanize.star", "humanize") 2 | load("render.star", "render") 3 | load("schema.star", "schema") 4 | load("time.star", "time") 5 | 6 | DEFAULT_COUNTER = "1337" 7 | DEFAULT_APPS = "42" 8 | DEFAULT_TIMEZONE = "America/New_York" 9 | 10 | def main(config): 11 | tz = config.get("$tz", DEFAULT_TIMEZONE) 12 | num_apps = config.get("num_apps", DEFAULT_APPS) 13 | now = time.now() 14 | 15 | return render.Root( 16 | child = render.Column( 17 | children = [ 18 | render.Text(" %s rated" % humanize.plural(int(num_apps), "app")), 19 | render.Text(" Comma: %s" % humanize.comma(int(config.get("count", "1337")))), 20 | ], 21 | ), 22 | ) 23 | 24 | def get_schema(): 25 | return schema.Schema( 26 | version = "1", 27 | fields = [ 28 | schema.Text( 29 | id = "count", 30 | name = "Count", 31 | desc = "A cool counter that has comma separators", 32 | icon = "number", 33 | default = DEFAULT_COUNTER, 34 | ), 35 | schema.Text( 36 | id = "num_apps", 37 | name = "How many apps do you want?", 38 | desc = "The number of apps", 39 | icon = "number", 40 | default = DEFAULT_APPS, 41 | ), 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /examples/qrcode/qrcode.star: -------------------------------------------------------------------------------- 1 | load("cache.star", "cache") 2 | load("encoding/base64.star", "base64") 3 | load("qrcode.star", "qrcode") 4 | load("render.star", "render") 5 | 6 | def main(config): 7 | url = "https://tidbyt.com?utm_source=pixlet_example" 8 | 9 | data = cache.get(url) 10 | if data == None: 11 | code = qrcode.generate( 12 | url = url, 13 | size = "large", 14 | color = "#fff", 15 | background = "#000", 16 | ) 17 | cache.set(url, base64.encode(code), ttl_seconds = 3600) 18 | else: 19 | code = base64.decode(data) 20 | 21 | return render.Root( 22 | child = render.Padding( 23 | child = render.Image(src = code), 24 | pad = 1, 25 | ), 26 | ) 27 | -------------------------------------------------------------------------------- /examples/schema_hello_world/schema_hello_world.star: -------------------------------------------------------------------------------- 1 | load("render.star", "render") 2 | load("schema.star", "schema") 3 | 4 | def main(config): 5 | message = "Hello, %s!" % config.get("who", "World") 6 | 7 | if config.bool("small"): 8 | msg = render.Text(message, font = "CG-pixel-3x5-mono") 9 | else: 10 | msg = render.Text(message) 11 | 12 | return render.Root( 13 | child = msg, 14 | ) 15 | 16 | def get_schema(): 17 | return schema.Schema( 18 | version = "1", 19 | fields = [ 20 | schema.Text( 21 | id = "who", 22 | name = "Who?", 23 | desc = "Who to say hello to.", 24 | icon = "user", 25 | ), 26 | schema.Toggle( 27 | id = "small", 28 | name = "Display small text", 29 | desc = "A toggle to display smaller text.", 30 | icon = "compress", 31 | default = False, 32 | ), 33 | ], 34 | ) 35 | -------------------------------------------------------------------------------- /globals/global.go: -------------------------------------------------------------------------------- 1 | package globals 2 | 3 | var Width = 64 4 | var Height = 32 5 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | "tidbyt.dev/pixlet/cmd" 8 | "tidbyt.dev/pixlet/cmd/community" 9 | ) 10 | 11 | var ( 12 | rootCmd = &cobra.Command{ 13 | Use: "pixlet", 14 | Short: "pixel graphics rendering", 15 | Long: "Pixlet renders graphics for pixel devices, like Tidbyt", 16 | SilenceUsage: true, 17 | } 18 | ) 19 | 20 | func init() { 21 | rootCmd.AddCommand(cmd.RenderCmd) 22 | rootCmd.AddCommand(cmd.PushCmd) 23 | rootCmd.AddCommand(cmd.EncryptCmd) 24 | rootCmd.AddCommand(cmd.VersionCmd) 25 | rootCmd.AddCommand(cmd.ProfileCmd) 26 | rootCmd.AddCommand(cmd.LoginCmd) 27 | rootCmd.AddCommand(cmd.DevicesCmd) 28 | rootCmd.AddCommand(cmd.ListCmd) 29 | rootCmd.AddCommand(cmd.DeleteCmd) 30 | rootCmd.AddCommand(cmd.FormatCmd) 31 | rootCmd.AddCommand(cmd.LintCmd) 32 | rootCmd.AddCommand(cmd.CheckCmd) 33 | rootCmd.AddCommand(cmd.SetAuthCmd) 34 | rootCmd.AddCommand(community.CommunityCmd) 35 | } 36 | 37 | func main() { 38 | if err := rootCmd.Execute(); err != nil { 39 | os.Exit(1) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /main_nonjs.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package main 4 | 5 | import ( 6 | "tidbyt.dev/pixlet/cmd" 7 | "tidbyt.dev/pixlet/cmd/private" 8 | ) 9 | 10 | func init() { 11 | rootCmd.AddCommand(private.PrivateCmd) 12 | rootCmd.AddCommand(cmd.CreateCmd) 13 | rootCmd.AddCommand(cmd.ServeCmd) 14 | } 15 | -------------------------------------------------------------------------------- /manifest/testdata/manifest.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | id: fuzzy-clock 3 | name: Fuzzy Clock 4 | summary: Human readable time 5 | desc: Display the time in a groovy, human-readable way. 6 | author: Max Timkovich 7 | -------------------------------------------------------------------------------- /manifest/testdata/source.star: -------------------------------------------------------------------------------- 1 | """ 2 | Applet: 3 | Summary: 4 | Description: 5 | Author: 6 | """ 7 | 8 | load("render.star", "render") 9 | 10 | def main(): 11 | return render.Root( 12 | child = render.Text("Hello, World!"), 13 | ) 14 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Service-Worker-Allowed: / -------------------------------------------------------------------------------- /render/animation.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/tidbyt/gg" 7 | ) 8 | 9 | // Animations turns a list of children into an animation, where each 10 | // child is a frame. 11 | // 12 | // FIXME: Behaviour when children themselves are animated is a bit 13 | // weird. Think and fix. 14 | // 15 | // DOC(Children): Children to use as frames in the animation 16 | // 17 | // EXAMPLE BEGIN 18 | // render.Animation( 19 | // children=[ 20 | // render.Box(width=10, height=10, color="#300"), 21 | // render.Box(width=12, height=12, color="#500"), 22 | // render.Box(width=14, height=14, color="#700"), 23 | // render.Box(width=16, height=16, color="#900"), 24 | // render.Box(width=18, height=18, color="#b00"), 25 | // ], 26 | // ) 27 | // EXAMPLE END 28 | type Animation struct { 29 | Widget 30 | Children []Widget 31 | } 32 | 33 | func (a Animation) FrameCount() int { 34 | return len(a.Children) 35 | } 36 | 37 | func (a Animation) PaintBounds(bounds image.Rectangle, frameIdx int) image.Rectangle { 38 | if len(a.Children) == 0 { 39 | return image.Rect(0, 0, 0, 0) 40 | } 41 | 42 | if frameIdx > len(a.Children) { 43 | frameIdx %= len(a.Children) 44 | if frameIdx < 0 { 45 | frameIdx += len(a.Children) 46 | } 47 | } 48 | 49 | return a.Children[ModInt(frameIdx, len(a.Children))].PaintBounds(bounds, frameIdx) 50 | } 51 | 52 | func (a Animation) Paint(dc *gg.Context, bounds image.Rectangle, frameIdx int) { 53 | if len(a.Children) == 0 { 54 | return 55 | } 56 | 57 | if frameIdx > len(a.Children) { 58 | frameIdx %= len(a.Children) 59 | if frameIdx < 0 { 60 | frameIdx += len(a.Children) 61 | } 62 | } 63 | 64 | a.Children[ModInt(frameIdx, len(a.Children))].Paint(dc, bounds, frameIdx) 65 | } 66 | -------------------------------------------------------------------------------- /render/animation/direction.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | type Direction interface { 4 | FrameCount(delay, duration int) int 5 | Progress(delay, duration int, fill float64, frameIdx int) float64 6 | } 7 | 8 | type DirectionImpl struct { 9 | Alternate bool 10 | Reverse bool 11 | } 12 | 13 | func (self DirectionImpl) FrameCount(delay, duration int) int { 14 | if self.Alternate { 15 | return 2 * (delay + duration) 16 | } 17 | 18 | return delay + duration + delay 19 | } 20 | 21 | func (self DirectionImpl) Progress(delay, duration int, fill float64, frameIdx int) (progress float64) { 22 | idx1 := delay 23 | idx2 := delay + duration 24 | idx3 := delay + duration + delay 25 | idx4 := delay + duration + delay + duration 26 | 27 | if frameIdx < idx1 { 28 | progress = 0.0 29 | } else if frameIdx < idx2 { 30 | progress = float64(frameIdx-idx1) / float64(duration-1) 31 | } else if frameIdx < idx3 { 32 | progress = 1.0 33 | } else if self.Alternate && frameIdx < idx4 { 34 | progress = float64(frameIdx-idx3) / float64(duration-1) 35 | progress = 1.0 - progress 36 | } else { 37 | progress = fill 38 | } 39 | 40 | if self.Reverse { 41 | progress = 1.0 - progress 42 | } 43 | 44 | return 45 | } 46 | 47 | var DirectionNormal = DirectionImpl{Alternate: false, Reverse: false} 48 | var DirectionReverse = DirectionImpl{Alternate: false, Reverse: true} 49 | var DirectionAlternate = DirectionImpl{Alternate: true, Reverse: false} 50 | var DirectionAlternateReverse = DirectionImpl{Alternate: true, Reverse: true} 51 | var DefaultDirection = DirectionNormal 52 | -------------------------------------------------------------------------------- /render/animation/fill_mode.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | type FillMode interface { 4 | Value() float64 5 | } 6 | 7 | type FillModeForwards struct{} 8 | 9 | func (self FillModeForwards) Value() float64 { 10 | return 1.0 11 | } 12 | 13 | type FillModeBackwards struct{} 14 | 15 | func (self FillModeBackwards) Value() float64 { 16 | return 0.0 17 | } 18 | 19 | var DefaultFillMode = FillModeForwards{} 20 | -------------------------------------------------------------------------------- /render/animation/keyframe.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | // A keyframe defining specific point in time in the animation. 4 | // 5 | // The keyframe _percentage_ can is expressed as a floating point value between `0.0` and `1.0`. 6 | // 7 | // DOC(Percentage): Percentage of the time at which this keyframe occurs through the animation. 8 | // DOC(Transforms): List of transforms at this keyframe to interpolate to or from. 9 | // DOC(Curve): Easing curve to use, default is 'linear' 10 | // 11 | type Keyframe struct { 12 | Percentage Percentage `starlark:"percentage,required"` 13 | Transforms []Transform `starlark:"transforms,required"` 14 | Curve Curve `starlark:"curve"` 15 | } 16 | -------------------------------------------------------------------------------- /render/animation/origin.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "image" 5 | ) 6 | 7 | // An relative anchor point to use for scaling and rotation transforms. 8 | // 9 | // DOC(X): Horizontal anchor point 10 | // DOC(Y): Vertical anchor point 11 | // 12 | type Origin struct { 13 | X Percentage `starlark:"x,required"` 14 | Y Percentage `starlark:"y,required"` 15 | } 16 | 17 | func (self Origin) Transform(bounds image.Rectangle) Vec2f { 18 | return Vec2f{ 19 | self.X.Value * float64(bounds.Dx()), 20 | self.Y.Value * float64(bounds.Dy()), 21 | } 22 | } 23 | 24 | var DefaultOrigin = Origin{ 25 | X: Percentage{0.5}, 26 | Y: Percentage{0.5}, 27 | } 28 | -------------------------------------------------------------------------------- /render/animation/origin_test.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestOrigin(t *testing.T) { 11 | r := image.Rect(0, 0, 100, 100) 12 | 13 | assert.Equal(t, Vec2f{X: 0.0, Y: 0.0}, Origin{X: Percentage{0.0}, Y: Percentage{0.0}}.Transform(r)) 14 | assert.Equal(t, Vec2f{X: 33.0, Y: 33.0}, Origin{X: Percentage{0.33}, Y: Percentage{0.33}}.Transform(r)) 15 | assert.Equal(t, Vec2f{X: 50.0, Y: 50.0}, Origin{X: Percentage{0.5}, Y: Percentage{0.5}}.Transform(r)) 16 | 17 | v := Origin{X: Percentage{0.666}, Y: Percentage{0.666}}.Transform(r) 18 | assert.InDelta(t, 66.6, v.X, 0.00001) 19 | assert.InDelta(t, 66.6, v.Y, 0.00001) 20 | 21 | assert.Equal(t, Vec2f{X: 100.0, Y: 100.0}, Origin{X: Percentage{1.0}, Y: Percentage{1.0}}.Transform(r)) 22 | } 23 | -------------------------------------------------------------------------------- /render/animation/percentage.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | type Percentage struct { 4 | Value float64 5 | } 6 | -------------------------------------------------------------------------------- /render/animation/rotate.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "github.com/tidbyt/gg" 5 | ) 6 | 7 | // Transform by rotating by a given angle in degrees. 8 | // 9 | // DOC(Angle): Angle to rotate by in degrees 10 | // 11 | type Rotate struct { 12 | Angle float64 `starlark:"angle,required"` 13 | } 14 | 15 | func (self Rotate) Apply(ctx *gg.Context, origin Vec2f, rounding Rounding) { 16 | ctx.RotateAbout(gg.Radians(self.Angle), origin.X, origin.Y) 17 | } 18 | 19 | func (self Rotate) Interpolate(other Transform, progress float64) (result Transform, ok bool) { 20 | if other, ok := other.(Rotate); ok { 21 | return Rotate{Lerp(self.Angle, other.Angle, progress)}, true 22 | } 23 | 24 | return RotateDefault, false 25 | } 26 | 27 | var RotateDefault = Rotate{0.0} 28 | -------------------------------------------------------------------------------- /render/animation/rotate_test.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func assertInterpolateRotate( 8 | t *testing.T, 9 | expected float64, 10 | from float64, 11 | to float64, 12 | progress float64, 13 | ) { 14 | AssertInterpolate(t, Rotate{Angle: expected}, Rotate{Angle: from}, Rotate{Angle: to}, progress) 15 | } 16 | 17 | func TestInterpolateRotate(t *testing.T) { 18 | from := 0.0 19 | to := 360.0 20 | 21 | assertInterpolateRotate(t, 0.0, from, to, 0.0) 22 | assertInterpolateRotate(t, 36.0, from, to, 0.1) 23 | assertInterpolateRotate(t, 72.0, from, to, 0.2) 24 | assertInterpolateRotate(t, 90.0, from, to, 0.25) 25 | assertInterpolateRotate(t, 180.0, from, to, 0.5) 26 | assertInterpolateRotate(t, 360.0, from, to, 1.0) 27 | assertInterpolateRotate(t, 720.0, from, to, 2.0) 28 | assertInterpolateRotate(t, -360.0, from, to, -1.0) 29 | } 30 | -------------------------------------------------------------------------------- /render/animation/rounding.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "math" 5 | ) 6 | 7 | type Rounding interface { 8 | Apply(v float64) float64 9 | } 10 | 11 | type Round struct{} 12 | 13 | func (self Round) Apply(v float64) float64 { 14 | return math.Round(v) 15 | } 16 | 17 | type RoundFloor struct{} 18 | 19 | func (self RoundFloor) Apply(v float64) float64 { 20 | return math.Floor(v) 21 | } 22 | 23 | type RoundCeil struct{} 24 | 25 | func (self RoundCeil) Apply(v float64) float64 { 26 | return math.Ceil(v) 27 | } 28 | 29 | type RoundNone struct{} 30 | 31 | func (self RoundNone) Apply(v float64) float64 { 32 | return v 33 | } 34 | 35 | var DefaultRounding = Round{} 36 | -------------------------------------------------------------------------------- /render/animation/scale.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "github.com/tidbyt/gg" 5 | ) 6 | 7 | // Transform by scaling by a given factor. 8 | // 9 | // DOC(X): Horizontal scale factor 10 | // DOC(Y): Vertical scale factor 11 | // 12 | type Scale struct { 13 | Vec2f 14 | } 15 | 16 | func (self Scale) Apply(ctx *gg.Context, origin Vec2f, rounding Rounding) { 17 | ctx.ScaleAbout(self.X, self.Y, origin.X, origin.Y) 18 | } 19 | 20 | func (self Scale) Interpolate(other Transform, progress float64) (result Transform, ok bool) { 21 | if other, ok := other.(Scale); ok { 22 | return Scale{self.Lerp(other.Vec2f, progress)}, true 23 | } 24 | 25 | return ScaleDefault, false 26 | } 27 | 28 | var ScaleDefault = Scale{Vec2f{1.0, 1.0}} 29 | -------------------------------------------------------------------------------- /render/animation/scale_test.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func assertInterpolateScale( 8 | t *testing.T, 9 | expected Vec2f, 10 | from Vec2f, 11 | to Vec2f, 12 | progress float64, 13 | ) { 14 | AssertInterpolate(t, Scale{expected}, Scale{from}, Scale{to}, progress) 15 | } 16 | 17 | func TestInterpolateScale(t *testing.T) { 18 | from := Vec2f{X: 0.0, Y: 0.0} 19 | to := Vec2f{X: 100.0, Y: 200.0} 20 | 21 | assertInterpolateScale(t, Vec2f{X: 0.0, Y: 0.0}, from, to, 0.0) 22 | assertInterpolateScale(t, Vec2f{X: 10.0, Y: 20.0}, from, to, 0.1) 23 | assertInterpolateScale(t, Vec2f{X: 33.0, Y: 66.0}, from, to, 0.33) 24 | assertInterpolateScale(t, Vec2f{X: 100.0, Y: 200.0}, from, to, 1.0) 25 | assertInterpolateScale(t, Vec2f{X: 1337.0, Y: 2674.0}, from, to, 13.37) 26 | } 27 | -------------------------------------------------------------------------------- /render/animation/transform.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "github.com/tidbyt/gg" 5 | ) 6 | 7 | type Transform interface { 8 | Apply(ctx *gg.Context, origin Vec2f, rounding Rounding) 9 | Interpolate(other Transform, progress float64) (result Transform, ok bool) 10 | } 11 | 12 | func ExtendTransforms(lhs []Transform, rhs []Transform) []Transform { 13 | for i, transform := range rhs { 14 | if i >= len(lhs) { 15 | switch transform.(type) { 16 | case Translate: 17 | lhs = append(lhs, TranslateDefault) 18 | case Scale: 19 | lhs = append(lhs, ScaleDefault) 20 | case Rotate: 21 | lhs = append(lhs, RotateDefault) 22 | } 23 | } 24 | } 25 | 26 | return lhs 27 | } 28 | 29 | // See: https://www.w3.org/TR/css-transforms-1/#interpolation-of-transforms 30 | func InterpolateTransforms(lhs, rhs []Transform, progress float64) (result []Transform, ok bool) { 31 | if len(lhs) == 0 && len(rhs) == 0 { 32 | return make([]Transform, 0), true 33 | } 34 | 35 | if len(lhs) < len(rhs) { 36 | lhs = ExtendTransforms(lhs, rhs) 37 | } else if len(lhs) > len(rhs) { 38 | rhs = ExtendTransforms(rhs, lhs) 39 | } 40 | 41 | result = make([]Transform, 0) 42 | 43 | for i := 0; i < len(lhs); i++ { 44 | if t, ok := lhs[i].Interpolate(rhs[i], progress); ok { 45 | result = append(result, t) 46 | } else { 47 | // This is the point where remaining transforms would be composed into matrices 48 | // and interpolated on the matrix level, but for simplicity is not supported. 49 | return make([]Transform, 0), false 50 | } 51 | } 52 | 53 | return result, true 54 | } 55 | -------------------------------------------------------------------------------- /render/animation/translate.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "github.com/tidbyt/gg" 5 | ) 6 | 7 | // Transform by translating by a given offset. 8 | // 9 | // DOC(X): Horizontal offset 10 | // DOC(Y): Vertical offset 11 | // 12 | type Translate struct { 13 | Vec2f 14 | } 15 | 16 | func (self Translate) Apply(ctx *gg.Context, origin Vec2f, rounding Rounding) { 17 | ctx.Translate(rounding.Apply(self.X), rounding.Apply(self.Y)) 18 | } 19 | 20 | func (self Translate) Interpolate(other Transform, progress float64) (result Transform, ok bool) { 21 | if other, ok := other.(Translate); ok { 22 | return Translate{self.Lerp(other.Vec2f, progress)}, true 23 | } 24 | 25 | return TranslateDefault, false 26 | } 27 | 28 | var TranslateDefault = Translate{Vec2f{0.0, 0.0}} 29 | -------------------------------------------------------------------------------- /render/animation/translate_test.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func assertInterpolateTranslate( 8 | t *testing.T, 9 | expected Vec2f, 10 | from Vec2f, 11 | to Vec2f, 12 | progress float64, 13 | ) { 14 | AssertInterpolate(t, Translate{expected}, Translate{from}, Translate{to}, progress) 15 | } 16 | 17 | func TestInterpolateTranslate(t *testing.T) { 18 | from := Vec2f{X: 0.0, Y: 0.0} 19 | to := Vec2f{X: 100.0, Y: 200.0} 20 | 21 | assertInterpolateTranslate(t, Vec2f{X: 0.0, Y: 0.0}, from, to, 0.0) 22 | assertInterpolateTranslate(t, Vec2f{X: 10.0, Y: 20.0}, from, to, 0.1) 23 | assertInterpolateTranslate(t, Vec2f{X: 33.0, Y: 66.0}, from, to, 0.33) 24 | assertInterpolateTranslate(t, Vec2f{X: 100.0, Y: 200.0}, from, to, 1.0) 25 | assertInterpolateTranslate(t, Vec2f{X: 1337.0, Y: 2674.0}, from, to, 13.37) 26 | } 27 | -------------------------------------------------------------------------------- /render/animation/util.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | func Rescale(fromMin, fromMax, toMin, toMax, v float64) float64 { 4 | if fromMax == fromMin { 5 | return toMax 6 | } 7 | 8 | return toMin + (v-fromMin)/(fromMax-fromMin)*(toMax-toMin) 9 | } 10 | 11 | func Lerp(from, to, t float64) float64 { 12 | return from + (to-from)*t 13 | } 14 | -------------------------------------------------------------------------------- /render/animation/util_test.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRescale(t *testing.T) { 10 | // [0.0, 1.0] -> [0.0, 100.0] 11 | assert.Equal(t, 0.0, Rescale(0.0, 1.0, 0.0, 100.0, 0.0)) 12 | assert.Equal(t, 50.0, Rescale(0.0, 1.0, 0.0, 100.0, 0.5)) 13 | assert.Equal(t, 100.0, Rescale(0.0, 1.0, 0.0, 100.0, 1.0)) 14 | 15 | // [0.0, 1.0] -> [-50.0, 100.0] 16 | assert.Equal(t, -50.0, Rescale(0.0, 1.0, -50.0, 100.0, 0.0)) 17 | assert.Equal(t, 25.0, Rescale(0.0, 1.0, -50.0, 100.0, 0.5)) 18 | assert.Equal(t, 100.0, Rescale(0.0, 1.0, -50.0, 100.0, 1.0)) 19 | 20 | // [-33.2, 10.71] -> [0.0, 1.0] 21 | assert.Equal(t, 0.5283534502391255, Rescale(-33.2, 10.71, 0.0, 1.0, -10)) 22 | assert.Equal(t, 0.8699612844454566, Rescale(-33.2, 10.71, 0.0, 1.0, 5)) 23 | assert.Equal(t, 0.9838305625142336, Rescale(-33.2, 10.71, 0.0, 1.0, 10)) 24 | } 25 | 26 | func TestLerp(t *testing.T) { 27 | // [0.0, 1.0] 28 | assert.Equal(t, 0.0, Lerp(0.0, 1.0, 0.0)) 29 | assert.Equal(t, 0.1, Lerp(0.0, 1.0, 0.1)) 30 | assert.Equal(t, 0.33, Lerp(0.0, 1.0, 0.33)) 31 | assert.Equal(t, 0.5, Lerp(0.0, 1.0, 0.5)) 32 | assert.Equal(t, 0.7533, Lerp(0.0, 1.0, 0.7533)) 33 | assert.Equal(t, 1.0, Lerp(0.0, 1.0, 1.0)) 34 | 35 | // [-1.0, 1.0] 36 | assert.Equal(t, -1.0, Lerp(-1.0, 1.0, 0.0)) 37 | assert.Equal(t, -0.8, Lerp(-1.0, 1.0, 0.1)) 38 | assert.Equal(t, -0.33999999999999997, Lerp(-1.0, 1.0, 0.33)) 39 | assert.Equal(t, 0.0, Lerp(-1.0, 1.0, 0.5)) 40 | assert.Equal(t, 0.5065999999999999, Lerp(-1.0, 1.0, 0.7533)) 41 | assert.Equal(t, 1.0, Lerp(-1.0, 1.0, 1.0)) 42 | 43 | // [0.0, 42.1337] 44 | assert.Equal(t, 0.0, Lerp(0.0, 42.1337, 0.0)) 45 | assert.Equal(t, 4.21337, Lerp(0.0, 42.1337, 0.1)) 46 | assert.Equal(t, 13.904121, Lerp(0.0, 42.1337, 0.33)) 47 | assert.Equal(t, 21.06685, Lerp(0.0, 42.1337, 0.5)) 48 | assert.Equal(t, 31.73931621, Lerp(0.0, 42.1337, 0.7533)) 49 | assert.Equal(t, 42.1337, Lerp(0.0, 42.1337, 1.0)) 50 | } 51 | -------------------------------------------------------------------------------- /render/animation/vector.go: -------------------------------------------------------------------------------- 1 | package animation 2 | 3 | type Vec2f struct { 4 | X float64 `starlark:"x,required"` 5 | Y float64 `starlark:"y,required"` 6 | } 7 | 8 | func (lhs Vec2f) Lerp(rhs Vec2f, progress float64) Vec2f { 9 | return Vec2f{ 10 | Lerp(lhs.X, rhs.X, progress), 11 | Lerp(lhs.Y, rhs.Y, progress), 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /render/circle.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "math" 7 | 8 | "github.com/tidbyt/gg" 9 | ) 10 | 11 | // Circle draws a circle with the given `diameter` and `color`. If a 12 | // `child` widget is provided, it is drawn in the center of the 13 | // circle. 14 | // 15 | // DOC(Child): Widget to place in the center of the circle 16 | // DOC(Color): Fill color 17 | // DOC(Diameter): Diameter of the circle 18 | // 19 | // EXAMPLE BEGIN 20 | // render.Circle( 21 | // color="#666", 22 | // diameter=30, 23 | // child=render.Circle(color="#0ff", diameter=10), 24 | // ) 25 | // EXAMPLE END 26 | type Circle struct { 27 | Widget 28 | 29 | Child Widget 30 | Color color.Color `starlark:"color, required"` 31 | Diameter int `starlark:"diameter,required"` 32 | } 33 | 34 | func (c Circle) PaintBounds(bounds image.Rectangle, frameIdx int) image.Rectangle { 35 | return image.Rect(0, 0, c.Diameter, c.Diameter) 36 | } 37 | 38 | func (c Circle) Paint(dc *gg.Context, bounds image.Rectangle, frameIdx int) { 39 | dc.SetColor(c.Color) 40 | 41 | r := float64(c.Diameter) / 2 42 | dc.DrawCircle(r, r, r) 43 | dc.Fill() 44 | 45 | if c.Child != nil { 46 | dc.Push() 47 | childBounds := c.Child.PaintBounds(image.Rect(0, 0, c.Diameter, c.Diameter), frameIdx) 48 | 49 | // This is a bit convoluted to obtain the same rounding behavior as with the old 50 | // local context rendering 51 | center := math.Ceil( 52 | float64(c.Diameter) / 2, 53 | ) 54 | x := int(center) 55 | y := int(center) 56 | x -= int(0.5 * float64(childBounds.Size().X)) 57 | y -= int(0.5 * float64(childBounds.Size().Y)) 58 | 59 | dc.Translate(float64(x), float64(y)) 60 | 61 | c.Child.Paint(dc, image.Rect(0, 0, c.Diameter, c.Diameter), frameIdx) 62 | dc.Pop() 63 | } 64 | } 65 | 66 | func (c Circle) FrameCount() int { 67 | if c.Child != nil { 68 | return c.Child.FrameCount() 69 | } 70 | return 1 71 | } 72 | -------------------------------------------------------------------------------- /render/colors.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "fmt" 5 | "image/color" 6 | "strings" 7 | ) 8 | 9 | func ParseColor(scol string) (color.Color, error) { 10 | var format string 11 | var fourBits bool 12 | var hasAlpha bool 13 | 14 | scol = strings.TrimPrefix(scol, "#") 15 | 16 | switch len(scol) { 17 | case 3: 18 | format = "%1x%1x%1x" 19 | fourBits = true 20 | hasAlpha = false 21 | case 4: 22 | format = "%1x%1x%1x%1x" 23 | fourBits = true 24 | hasAlpha = true 25 | case 6: 26 | format = "%02x%02x%02x" 27 | fourBits = false 28 | hasAlpha = false 29 | case 8: 30 | format = "%02x%02x%02x%02x" 31 | fourBits = false 32 | hasAlpha = true 33 | default: 34 | return color.Gray{0}, fmt.Errorf("color: %v is not a hex-color", scol) 35 | } 36 | 37 | var r, g, b, a uint8 38 | 39 | if hasAlpha { 40 | n, err := fmt.Sscanf(scol, format, &r, &g, &b, &a) 41 | if err != nil { 42 | return color.Gray{0}, err 43 | } 44 | if n != 4 { 45 | return color.Gray{0}, fmt.Errorf("color: %v is not a hex-color", scol) 46 | } 47 | } else { 48 | n, err := fmt.Sscanf(scol, format, &r, &g, &b) 49 | if err != nil { 50 | return color.Gray{0}, err 51 | } 52 | if n != 3 { 53 | return color.Gray{0}, fmt.Errorf("color: %v is not a hex-color", scol) 54 | } 55 | if fourBits { 56 | a = 15 57 | } else { 58 | a = 255 59 | } 60 | } 61 | 62 | if fourBits { 63 | r |= r << 4 64 | g |= g << 4 65 | b |= b << 4 66 | a |= a << 4 67 | } 68 | 69 | return color.NRGBA{r, g, b, a}, nil 70 | } 71 | -------------------------------------------------------------------------------- /render/colors_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image/color" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func ParseAndAssertColor( 11 | t *testing.T, 12 | scol string, 13 | expectedR uint8, 14 | expectedG uint8, 15 | expectedB uint8, 16 | expectedA uint8, 17 | ) { 18 | col, err := ParseColor(scol) 19 | assert.Nil(t, err) 20 | 21 | c, ok := col.(color.NRGBA) 22 | assert.True(t, ok) 23 | 24 | assert.Equal(t, expectedR, c.R) 25 | assert.Equal(t, expectedG, c.G) 26 | assert.Equal(t, expectedB, c.B) 27 | assert.Equal(t, expectedA, c.A) 28 | } 29 | 30 | func TestParseColorRGB(t *testing.T) { 31 | ParseAndAssertColor(t, "#5ad", 0x55, 0xaa, 0xdd, 0xff) 32 | ParseAndAssertColor(t, "5ad", 0x55, 0xaa, 0xdd, 0xff) 33 | } 34 | 35 | func TestParseColorRGBA(t *testing.T) { 36 | ParseAndAssertColor(t, "#5ad8", 0x55, 0xaa, 0xdd, 0x88) 37 | ParseAndAssertColor(t, "5ad8", 0x55, 0xaa, 0xdd, 0x88) 38 | } 39 | 40 | func TestParseColorRRGGBB(t *testing.T) { 41 | ParseAndAssertColor(t, "#257adb", 0x25, 0x7a, 0xdb, 0xff) 42 | ParseAndAssertColor(t, "257adb", 0x25, 0x7a, 0xdb, 0xff) 43 | } 44 | 45 | func TestParseColorRRGGBBAA(t *testing.T) { 46 | ParseAndAssertColor(t, "#257adb75", 0x25, 0x7a, 0xdb, 0x75) 47 | ParseAndAssertColor(t, "257adb75", 0x25, 0x7a, 0xdb, 0x75) 48 | } 49 | 50 | func TestParseColorBadValue(t *testing.T) { 51 | _, err := ParseColor("5a") 52 | assert.Error(t, err) 53 | 54 | _, err = ParseColor("#5a") 55 | assert.Error(t, err) 56 | 57 | _, err = ParseColor("#5ad8f") 58 | assert.Error(t, err) 59 | 60 | _, err = ParseColor("5ad8f") 61 | assert.Error(t, err) 62 | 63 | _, err = ParseColor("#5ad8f33da") 64 | assert.Error(t, err) 65 | 66 | _, err = ParseColor("#xyz") 67 | assert.Error(t, err) 68 | 69 | _, err = ParseColor("##abc") 70 | assert.Error(t, err) 71 | } 72 | -------------------------------------------------------------------------------- /render/column_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // Column is just a Vector. See vector_test.go for full coverage. 12 | 13 | func TestColumnPaint(t *testing.T) { 14 | c := Column{ 15 | Expanded: true, 16 | MainAlign: "space_evenly", 17 | CrossAlign: "center", 18 | Children: []Widget{ 19 | // A green box 20 | Box{Width: 6, Height: 7, Color: color.RGBA{0, 0xff, 0, 0xff}}, 21 | // A red box 22 | Box{Width: 8, Height: 9, Color: color.RGBA{0xff, 0, 0, 0xff}}, 23 | }, 24 | } 25 | 26 | // On large canvas, height gets truncated to max of children, 27 | // while width expands to full size 28 | im := PaintWidget(c, image.Rect(0, 0, 25, 16+3), 0) 29 | assert.Equal(t, nil, checkImage([]string{ 30 | "........", 31 | ".gggggg.", 32 | ".gggggg.", 33 | ".gggggg.", 34 | ".gggggg.", 35 | ".gggggg.", 36 | ".gggggg.", 37 | ".gggggg.", 38 | "........", 39 | "rrrrrrrr", 40 | "rrrrrrrr", 41 | "rrrrrrrr", 42 | "rrrrrrrr", 43 | "rrrrrrrr", 44 | "rrrrrrrr", 45 | "rrrrrrrr", 46 | "rrrrrrrr", 47 | "rrrrrrrr", 48 | "........", 49 | }, im)) 50 | } 51 | -------------------------------------------------------------------------------- /render/fonts.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | //go:generate go run gen/embedfonts.go 4 | 5 | import ( 6 | "encoding/base64" 7 | "fmt" 8 | "sync" 9 | 10 | "github.com/zachomedia/go-bdf" 11 | "golang.org/x/image/font" 12 | ) 13 | 14 | var fontCache = map[string]font.Face{} 15 | var fontMutex = &sync.Mutex{} 16 | 17 | func GetFontList() []string { 18 | fontNames := []string{} 19 | for key := range fontDataRaw { 20 | fontNames = append(fontNames, key) 21 | } 22 | return fontNames 23 | } 24 | 25 | func GetFont(name string) (font.Face, error) { 26 | fontMutex.Lock() 27 | defer fontMutex.Unlock() 28 | 29 | if font, ok := fontCache[name]; ok { 30 | return font, nil 31 | } 32 | 33 | dataB64, ok := fontDataRaw[name] 34 | if !ok { 35 | return nil, fmt.Errorf("unknown font '%s'", name) 36 | } 37 | 38 | data, err := base64.StdEncoding.DecodeString(dataB64) 39 | if err != nil { 40 | return nil, fmt.Errorf("decoding font '%s': %w", name, err) 41 | } 42 | 43 | f, err := bdf.Parse(data) 44 | if err != nil { 45 | return nil, fmt.Errorf("parsing font '%s': %w", name, err) 46 | } 47 | 48 | fontCache[name] = f.NewFace() 49 | return fontCache[name], nil 50 | } 51 | -------------------------------------------------------------------------------- /render/gen/embedfonts.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "strings" 9 | "text/template" 10 | ) 11 | 12 | const FontDir = "./fonts" 13 | const OutFile = "render/fonts_raw.go" 14 | 15 | type Font struct { 16 | Name string 17 | DataB64 string 18 | } 19 | 20 | const fontsTemplate = `// Code generated by embedfonts.go, DO NOT EDIT. 21 | 22 | package render 23 | 24 | var fontDataRaw = map[string]string{ 25 | {{range .}} "{{.Name}}": "{{.DataB64}}", 26 | {{end}} 27 | } 28 | 29 | ` 30 | 31 | func main() { 32 | fontFileInfos, err := ioutil.ReadDir(FontDir) 33 | if err != nil { 34 | fmt.Printf("ioutil.ReadDir(%s): %s\n", FontDir, err) 35 | os.Exit(1) 36 | } 37 | 38 | fonts := []Font{} 39 | for _, ffi := range fontFileInfos { 40 | if !strings.HasSuffix(ffi.Name(), ".bdf") { 41 | continue 42 | } 43 | 44 | name := strings.TrimSuffix(ffi.Name(), ".bdf") 45 | path := fmt.Sprintf("%s/%s", FontDir, ffi.Name()) 46 | 47 | content, err := ioutil.ReadFile(path) 48 | if err != nil { 49 | fmt.Printf("ioutil.Readfile(%s): %s\n", path, err) 50 | os.Exit(1) 51 | } 52 | 53 | fonts = append(fonts, Font{ 54 | Name: name, 55 | DataB64: base64.StdEncoding.EncodeToString(content), 56 | }) 57 | } 58 | 59 | tmpl, err := template.New("fonts").Parse(fontsTemplate) 60 | if err != nil { 61 | fmt.Println("template.New().Parse()", err) 62 | os.Exit(1) 63 | } 64 | 65 | out, err := os.Create(OutFile) 66 | if err != nil { 67 | fmt.Printf("os.Create(%s): %s\n", OutFile, err) 68 | os.Exit(1) 69 | } 70 | 71 | err = tmpl.Execute(out, fonts) 72 | if err != nil { 73 | fmt.Println("template.Execute()", err) 74 | os.Exit(1) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /render/image_webp.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package render 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/tidbyt/go-libwebp/webp" 9 | ) 10 | 11 | func (p *Image) InitFromWebP(data []byte) error { 12 | decoder, err := webp.NewAnimationDecoder(data) 13 | if err != nil { 14 | return fmt.Errorf("creating animation decoder: %v", err) 15 | } 16 | 17 | img, err := decoder.Decode() 18 | if err != nil { 19 | return fmt.Errorf("decoding image data: %v", err) 20 | } 21 | 22 | p.Delay = img.Timestamp[0] 23 | for _, im := range img.Image { 24 | p.imgs = append(p.imgs, im) 25 | } 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /render/image_webp_js.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package render 4 | 5 | import ( 6 | "fmt" 7 | ) 8 | 9 | func (p *Image) InitFromWebP(data []byte) error { 10 | return fmt.Errorf("WebP not supported in WASM") 11 | } 12 | -------------------------------------------------------------------------------- /render/pie_chart.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "math" 7 | 8 | "github.com/tidbyt/gg" 9 | ) 10 | 11 | // PieChart draws a circular pie chart of size `diameter`. It takes two 12 | // arguments for the data: parallel lists `colors` and `weights` representing 13 | // the shading and relative sizes of each data entry. 14 | // 15 | // DOC(Colors): List of color hex codes 16 | // DOC(Weights): List of numbers corresponding to the relative size of each color 17 | // DOC(Diameter): Diameter of the circle 18 | // 19 | // EXAMPLE BEGIN 20 | // render.PieChart( 21 | // colors = [ "#fff", "#0f0", "#00f" ], 22 | // weights = [ 180, 135, 45 ], 23 | // diameter = 30, 24 | // ) 25 | // EXAMPLE END 26 | type PieChart struct { 27 | Widget 28 | 29 | Colors []color.Color `starlark:"colors, required"` 30 | Weights []float64 `starlark:"weights, required"` 31 | Diameter int `starlark:"diameter,required"` 32 | } 33 | 34 | func (c PieChart) PaintBounds(bounds image.Rectangle, frameIdx int) image.Rectangle { 35 | return image.Rect(0, 0, c.Diameter, c.Diameter) 36 | } 37 | 38 | func (c PieChart) Paint(dc *gg.Context, bounds image.Rectangle, frameIdx int) { 39 | total := 0.0 40 | for _, v := range c.Weights { 41 | total += v 42 | } 43 | 44 | r := float64(c.Diameter) / 2 45 | 46 | start := 0.0 47 | for i, v := range c.Weights { 48 | end := start + v/total 49 | dc.SetColor(c.Colors[i%len(c.Colors)]) 50 | dc.DrawArc(r, r, r, start*2*math.Pi, end*2*math.Pi) 51 | dc.LineTo(r, r) 52 | dc.LineTo(r+r*math.Cos(start*2*math.Pi), r+r*math.Sin(start*2*math.Pi)) 53 | dc.Fill() 54 | start = end 55 | } 56 | } 57 | 58 | func (c PieChart) FrameCount() int { 59 | return 1 60 | } 61 | -------------------------------------------------------------------------------- /render/row_test.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // Row is just a Vector. See vector_test.go for full coverage. 12 | 13 | func TestRowPaint(t *testing.T) { 14 | r := Row{ 15 | Expanded: true, 16 | MainAlign: "space_evenly", 17 | CrossAlign: "end", 18 | Children: []Widget{ 19 | // A green box 20 | Box{Width: 6, Height: 7, Color: color.RGBA{0, 0xff, 0, 0xff}}, 21 | // A red box 22 | Box{Width: 8, Height: 9, Color: color.RGBA{0xff, 0, 0, 0xff}}, 23 | }, 24 | } 25 | 26 | // On large canvas, height gets truncated to max of children, 27 | // while width expands to full size 28 | im := PaintWidget(r, image.Rect(0, 0, 14+2, 17), 0) 29 | assert.Equal(t, nil, checkImage([]string{ 30 | "........rrrrrrrr", 31 | "........rrrrrrrr", 32 | ".gggggg.rrrrrrrr", 33 | ".gggggg.rrrrrrrr", 34 | ".gggggg.rrrrrrrr", 35 | ".gggggg.rrrrrrrr", 36 | ".gggggg.rrrrrrrr", 37 | ".gggggg.rrrrrrrr", 38 | ".gggggg.rrrrrrrr", 39 | }, im)) 40 | } 41 | -------------------------------------------------------------------------------- /render/sequence.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/tidbyt/gg" 7 | ) 8 | 9 | // Sequence renders a list of child widgets in sequence. 10 | // 11 | // Each child widget is rendered for the duration of its 12 | // frame count, then the next child wiget in the list will 13 | // be rendered and so on. 14 | // 15 | // It comes in quite useful when chaining animations. 16 | // If you want to know more about that, go check 17 | // out the [animation](animation.md) documentation. 18 | // 19 | // DOC(Children): List of child widgets 20 | // 21 | // EXAMPLE BEGIN 22 | // render.Sequence( 23 | // children = [ 24 | // animation.Transformation(...), 25 | // animation.Transformation(...), 26 | // ... 27 | // ], 28 | // ), 29 | // EXAMPLE END 30 | type Sequence struct { 31 | Widget 32 | 33 | Children []Widget `starlark:"children,required"` 34 | } 35 | 36 | func (s Sequence) FrameCount() int { 37 | fc := 0 38 | 39 | for _, c := range s.Children { 40 | fc += c.FrameCount() 41 | } 42 | 43 | return fc 44 | } 45 | 46 | func (s Sequence) PaintBounds(bounds image.Rectangle, frameIdx int) image.Rectangle { 47 | fc := 0 48 | 49 | for _, c := range s.Children { 50 | if frameIdx < fc+c.FrameCount() { 51 | return c.PaintBounds(bounds, frameIdx-fc) 52 | } 53 | 54 | fc += c.FrameCount() 55 | } 56 | 57 | return image.Rect(0, 0, 0, 0) 58 | } 59 | 60 | func (s Sequence) Paint(dc *gg.Context, bounds image.Rectangle, frameIdx int) { 61 | fc := 0 62 | 63 | for _, c := range s.Children { 64 | if frameIdx < fc+c.FrameCount() { 65 | dc.Push() 66 | c.Paint(dc, bounds, frameIdx-fc) 67 | dc.Pop() 68 | break 69 | } 70 | 71 | fc += c.FrameCount() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /render/stack.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/tidbyt/gg" 7 | ) 8 | 9 | // Stack draws its children on top of each other. 10 | // 11 | // Just like a stack of pancakes, except with Widgets instead of 12 | // pancakes. The Stack will be given a width and height sufficient to 13 | // fit all its children. 14 | // 15 | // DOC(Children): Widgets to stack 16 | // 17 | // EXAMPLE BEGIN 18 | // render.Stack( 19 | // 20 | // children=[ 21 | // render.Box(width=50, height=25, color="#911"), 22 | // render.Text("hello there"), 23 | // render.Box(width=4, height=32, color="#119"), 24 | // ], 25 | // 26 | // ) 27 | // EXAMPLE END 28 | type Stack struct { 29 | Widget 30 | Children []Widget `starlark:"children,required"` 31 | } 32 | 33 | func (s Stack) PaintBounds(bounds image.Rectangle, frameIdx int) image.Rectangle { 34 | width, height := 0, 0 35 | for _, child := range s.Children { 36 | cb := child.PaintBounds(bounds, frameIdx) 37 | imW, imH := cb.Dx(), cb.Dy() 38 | if imW > width { 39 | width = imW 40 | } 41 | if imH > height { 42 | height = imH 43 | } 44 | } 45 | 46 | if width > bounds.Dx() { 47 | width = bounds.Dx() 48 | } 49 | if height > bounds.Dy() { 50 | height = bounds.Dy() 51 | } 52 | 53 | return image.Rect(0, 0, width, height) 54 | } 55 | 56 | func (s Stack) Paint(dc *gg.Context, bounds image.Rectangle, frameIdx int) { 57 | for _, child := range s.Children { 58 | dc.Push() 59 | child.Paint(dc, bounds, frameIdx) 60 | dc.Pop() 61 | } 62 | } 63 | 64 | func (s Stack) FrameCount() int { 65 | return MaxFrameCount(s.Children) 66 | } 67 | -------------------------------------------------------------------------------- /render/tracer.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "github.com/tidbyt/gg" 8 | ) 9 | 10 | type Tracer struct { 11 | Widget 12 | Path Path 13 | TraceLength int 14 | } 15 | 16 | func (t Tracer) FrameCount() int { 17 | return t.Path.Length() 18 | } 19 | 20 | func (t Tracer) PaintBounds(bounds image.Rectangle, frameIdx int) image.Rectangle { 21 | width, height := t.Path.Size() 22 | return image.Rect(0, 0, width, height) 23 | } 24 | 25 | func (t Tracer) Paint(dc *gg.Context, bounds image.Rectangle, frameIdx int) { 26 | x, y := t.Path.Point(frameIdx) 27 | 28 | dc.SetColor(color.RGBA{0xff, 0xff, 0xff, 0xff}) 29 | tx, ty := dc.TransformPoint(float64(x), float64(y)) 30 | dc.SetPixel(int(tx), int(ty)) 31 | 32 | for i := 0; i < t.TraceLength; i++ { 33 | col := uint8(0xdd - i*(0xff/t.TraceLength)) 34 | dc.SetColor(color.RGBA{col, col, col, 0xff}) 35 | x, y := t.Path.Point(frameIdx - (i + 1)) 36 | tx, ty := dc.TransformPoint(float64(x), float64(y)) 37 | dc.SetPixel(int(tx), int(ty)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /render/widget.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/tidbyt/gg" 7 | ) 8 | 9 | // A Widget is a self-contained object that can render itself as an image. 10 | type Widget interface { 11 | // PaintBounds Returns the bounds of the area that will actually be drawn to when Paint() is called 12 | PaintBounds(bounds image.Rectangle, frameIdx int) image.Rectangle 13 | Paint(dc *gg.Context, bounds image.Rectangle, frameIdx int) 14 | FrameCount() int 15 | } 16 | 17 | // Widgets can require initialization 18 | type WidgetWithInit interface { 19 | Init() error 20 | } 21 | 22 | // WidgetStaticSize has inherent size and width known before painting. 23 | type WidgetStaticSize interface { 24 | Size() (int, int) 25 | } 26 | 27 | // Computes a (mod m). Useful for handling frameIdx > num available 28 | // frames in Widget.Paint() 29 | func ModInt(a, m int) int { 30 | a = a % m 31 | if a < 0 { 32 | a += m 33 | } 34 | return a 35 | } 36 | 37 | // Computes the maximum frame count of a slice of widgets. 38 | func MaxFrameCount(widgets []Widget) int { 39 | m := 1 40 | 41 | for _, w := range widgets { 42 | if c := w.FrameCount(); c > m { 43 | m = c 44 | } 45 | } 46 | 47 | return m 48 | } 49 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageRules": [ 3 | { 4 | "matchUpdateTypes": [ 5 | "minor", 6 | "patch", 7 | "pin", 8 | "digest" 9 | ], 10 | "automerge": true 11 | } 12 | ], 13 | "extends": [ 14 | "config:base" 15 | ] 16 | } -------------------------------------------------------------------------------- /runtime/gen/attr/bool.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}}w.{{.GoName}} = bool({{.StarlarkName}}){{end}} 2 | -------------------------------------------------------------------------------- /runtime/gen/attr/child.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | if {{.StarlarkName}} != nil { 3 | {{.StarlarkName}}Widget, ok := {{.StarlarkName}}.({{.GoWidgetName}}) 4 | if !ok { 5 | return nil, fmt.Errorf( 6 | "invalid type for {{.StarlarkName}}: %s (expected Widget)", 7 | {{.StarlarkName}}.Type(), 8 | ) 9 | } 10 | w.{{.GoName}} = {{.StarlarkName}}Widget.AsRenderWidget() 11 | w.starlark{{.GoName}} = {{.StarlarkName}} 12 | } 13 | {{end}} 14 | -------------------------------------------------------------------------------- /runtime/gen/attr/children.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | var {{.StarlarkName}}Val starlark.Value 3 | {{.StarlarkName}}Iter := {{.StarlarkName}}.Iterate() 4 | defer {{.StarlarkName}}Iter.Done() 5 | for i := 0; {{.StarlarkName}}Iter.Next(&{{.StarlarkName}}Val); { 6 | if _, isNone := {{.StarlarkName}}Val.(starlark.NoneType); isNone { 7 | continue 8 | } 9 | 10 | {{.StarlarkName}}Child, ok := {{.StarlarkName}}Val.({{.GoWidgetName}}) 11 | if !ok { 12 | return nil, fmt.Errorf( 13 | "expected {{.StarlarkName}} to be a list of Widget but found: %s (at index %d)", 14 | {{.StarlarkName}}Val.Type(), 15 | i, 16 | ) 17 | } 18 | 19 | w.{{.GoName}} = append(w.{{.GoName}}, {{.StarlarkName}}Child.AsRenderWidget()) 20 | } 21 | w.starlark{{.GoName}} = {{.StarlarkName}} 22 | {{end}} 23 | -------------------------------------------------------------------------------- /runtime/gen/attr/color.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | if {{.StarlarkName}}.Len() > 0 { 4 | c, err := render.ParseColor({{.StarlarkName}}.GoString()) 5 | if err != nil { 6 | return nil, fmt.Errorf("{{.StarlarkName}} is not a valid hex string: %s", {{.StarlarkName}}.String()) 7 | } 8 | w.{{.GoName}} = c 9 | } 10 | {{end}} 11 | -------------------------------------------------------------------------------- /runtime/gen/attr/colors.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | if val, err := ColorSeriesFromStarlark({{.StarlarkName}}); err == nil { 4 | w.{{.GoName}} = val 5 | } else { 6 | return nil, err 7 | } 8 | {{end}} 9 | -------------------------------------------------------------------------------- /runtime/gen/attr/curve.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | if {{.StarlarkName}} == nil { 4 | w.{{.GoName}} = animation.DefaultCurve 5 | } else if val, err := CurveFromStarlark({{.StarlarkName}}); err == nil { 6 | w.{{.GoName}} = val 7 | } else { 8 | return nil, err 9 | } 10 | {{end}} 11 | -------------------------------------------------------------------------------- /runtime/gen/attr/datapoint.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | if val, err := DataPointFromStarlark({{.StarlarkName}}); err == nil { 4 | w.{{.GoName}} = val 5 | } else { 6 | return nil, err 7 | } 8 | {{end}} 9 | -------------------------------------------------------------------------------- /runtime/gen/attr/dataseries.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | if val, err := DataSeriesFromStarlark({{.StarlarkName}}); err == nil { 4 | w.{{.GoName}} = val 5 | } else { 6 | return nil, err 7 | } 8 | {{end}} 9 | -------------------------------------------------------------------------------- /runtime/gen/attr/direction.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | switch {{.StarlarkName}} { 4 | case "normal": 5 | w.{{.GoName}} = animation.DirectionNormal 6 | case "reverse": 7 | w.{{.GoName}} = animation.DirectionReverse 8 | case "alternate": 9 | w.{{.GoName}} = animation.DirectionAlternate 10 | case "alternate-reverse": 11 | w.{{.GoName}} = animation.DirectionAlternateReverse 12 | case "": 13 | w.{{.GoName}} = animation.DefaultDirection 14 | default: 15 | return nil, fmt.Errorf("invalid type for direction: %s (expected 'normal', 'reverse', 'alternate' or 'alternate-reverse')", {{.StarlarkName}}.Type()) 16 | } 17 | {{end}} 18 | -------------------------------------------------------------------------------- /runtime/gen/attr/fill_mode.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | switch {{.StarlarkName}} { 4 | case "forwards": 5 | w.{{.GoName}} = animation.FillModeForwards{} 6 | case "backwards": 7 | w.{{.GoName}} = animation.FillModeBackwards{} 8 | case "": 9 | w.{{.GoName}} = animation.DefaultFillMode 10 | default: 11 | return nil, fmt.Errorf("invalid type for fill_mode: %s (expected 'forwards' or 'backwards')", {{.StarlarkName}}.Type()) 12 | } 13 | {{end}} 14 | -------------------------------------------------------------------------------- /runtime/gen/attr/float.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | if val, ok := starlark.AsFloat(w.starlark{{.GoName}}); ok { 4 | w.{{.GoName}} = val 5 | } else { 6 | return nil, fmt.Errorf("expected number, but got: %s", w.starlark{{.GoName}}.String()) 7 | } 8 | {{end}} 9 | -------------------------------------------------------------------------------- /runtime/gen/attr/insets.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | switch {{.StarlarkName}}Val := {{.StarlarkName}}.(type) { 4 | case starlark.Int: 5 | {{.StarlarkName}}Int := int({{.StarlarkName}}Val.BigInt().Int64()) 6 | w.{{.GoName}}.Left = {{.StarlarkName}}Int 7 | w.{{.GoName}}.Top = {{.StarlarkName}}Int 8 | w.{{.GoName}}.Right = {{.StarlarkName}}Int 9 | w.{{.GoName}}.Bottom = {{.StarlarkName}}Int 10 | case starlark.Tuple: 11 | {{.StarlarkName}}List := []starlark.Value({{.StarlarkName}}Val) 12 | if len({{.StarlarkName}}List) != 4 { 13 | return nil, fmt.Errorf( 14 | "{{.StarlarkName}} tuple must hold 4 elements (left, top, right, bottom), found %d", 15 | len({{.StarlarkName}}List), 16 | ) 17 | } 18 | {{.StarlarkName}}ListInt := make([]starlark.Int, 4) 19 | for i := 0; i < 4; i++ { 20 | pi, ok := {{.StarlarkName}}List[i].(starlark.Int) 21 | if !ok { 22 | return nil, fmt.Errorf("{{.StarlarkName}} element %d is not int", i) 23 | } 24 | {{.StarlarkName}}ListInt[i] = pi 25 | } 26 | w.{{.GoName}}.Left = int({{.StarlarkName}}ListInt[0].BigInt().Int64()) 27 | w.{{.GoName}}.Top = int({{.StarlarkName}}ListInt[1].BigInt().Int64()) 28 | w.{{.GoName}}.Right = int({{.StarlarkName}}ListInt[2].BigInt().Int64()) 29 | w.{{.GoName}}.Bottom = int({{.StarlarkName}}ListInt[3].BigInt().Int64()) 30 | default: 31 | return nil, fmt.Errorf("{{.StarlarkName}} must be int or 4-tuple of int") 32 | } 33 | {{end}} 34 | -------------------------------------------------------------------------------- /runtime/gen/attr/int.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}}w.{{.GoName}} = int({{.StarlarkName}}.BigInt().Int64()){{end}} 2 | -------------------------------------------------------------------------------- /runtime/gen/attr/int32.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | if val, err := starlark.AsInt32({{.StarlarkName}}); err == nil { 3 | w.{{.GoName}} = int32(val) 4 | } else { 5 | return nil, err 6 | } 7 | {{end}} 8 | -------------------------------------------------------------------------------- /runtime/gen/attr/keyframes.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | for i := 0; i < {{.StarlarkName}}.Len(); i++ { 4 | if val, ok := {{.StarlarkName}}.Index(i).(*Keyframe); ok { 5 | w.{{.GoName}} = append(w.{{.GoName}}, val.Keyframe) 6 | } else { 7 | return nil, fmt.Errorf("invalid type for keyframes: %s (expected Keyframe)", {{.StarlarkName}}.Type()) 8 | } 9 | } 10 | {{end}} 11 | -------------------------------------------------------------------------------- /runtime/gen/attr/origin.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | if {{.StarlarkName}} == nil { 4 | w.{{.GoName}} = animation.DefaultOrigin 5 | } else if val, ok := {{.StarlarkName}}.(*Origin); ok { 6 | w.{{.GoName}} = val.Origin 7 | } else { 8 | return nil, fmt.Errorf("invalid type for origin: %s (expected Origin)", {{.StarlarkName}}.Type()) 9 | } 10 | {{end}} 11 | -------------------------------------------------------------------------------- /runtime/gen/attr/pct.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | { 4 | if val, err := PercentageFromStarlark({{.StarlarkName}}, map[string]float64{"from": 0.0, "to": 1.0}); err == nil { 5 | w.{{.GoName}} = val 6 | } else { 7 | return nil, err 8 | } 9 | } 10 | {{end}} 11 | -------------------------------------------------------------------------------- /runtime/gen/attr/percentage.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | if val, err := PercentageFromStarlark({{.StarlarkName}}); err == nil { 4 | w.{{.GoName}} = val 5 | } else { 6 | return nil, err 7 | } 8 | {{end}} 9 | -------------------------------------------------------------------------------- /runtime/gen/attr/rounding.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | switch {{.StarlarkName}} { 4 | case "round": 5 | w.{{.GoName}} = animation.Round{} 6 | case "floor": 7 | w.{{.GoName}} = animation.RoundFloor{} 8 | case "ceil": 9 | w.{{.GoName}} = animation.RoundCeil{} 10 | case "none": 11 | w.{{.GoName}} = animation.RoundNone{} 12 | case "": 13 | w.{{.GoName}} = animation.DefaultRounding 14 | default: 15 | return nil, fmt.Errorf("invalid type for rounding: %s (expected 'round', 'floor', 'ceil' or 'none')", {{.StarlarkName}}.Type()) 16 | } 17 | {{end}} 18 | -------------------------------------------------------------------------------- /runtime/gen/attr/string.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}}w.{{.GoName}} = {{.StarlarkName}}.GoString(){{end}} 2 | -------------------------------------------------------------------------------- /runtime/gen/attr/transforms.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | for i := 0; i < {{.StarlarkName}}.Len(); i++ { 4 | switch {{.StarlarkName}}Val := {{.StarlarkName}}.Index(i).(type) { 5 | case *Translate: 6 | w.{{.GoName}} = append(w.{{.GoName}}, {{.StarlarkName}}Val.Translate) 7 | case *Scale: 8 | w.{{.GoName}} = append(w.{{.GoName}}, {{.StarlarkName}}Val.Scale) 9 | case *Rotate: 10 | w.{{.GoName}} = append(w.{{.GoName}}, {{.StarlarkName}}Val.Rotate) 11 | default: 12 | return nil, fmt.Errorf("expected transform, but got '%s'", {{.StarlarkName}}Val.Type()) 13 | } 14 | } 15 | {{end}} 16 | -------------------------------------------------------------------------------- /runtime/gen/attr/weights.tmpl: -------------------------------------------------------------------------------- 1 | {{if not .IsReadOnly}} 2 | w.starlark{{.GoName}} = {{.StarlarkName}} 3 | if val, err := WeightsFromStarlark({{.StarlarkName}}); err == nil { 4 | w.{{.GoName}} = val 5 | } else { 6 | return nil, err 7 | } 8 | {{end}} 9 | -------------------------------------------------------------------------------- /runtime/gen/docs/animation.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Animations! 4 | 5 | Pixlet supports a few animation primitives. These are used to animate 6 | widgets from frame to frame. 7 | 8 | All animations allow specifying an easing curve. This can either be one 9 | of the built-in "linear", "ease_in", "ease_out" or "ease_in_out" curves, 10 | a custom cubic bézier curve in the form "cubic-bezier(a, b, c, d)" or a 11 | custom easing function. 12 | 13 | **Warning**: The animation module is in a state of flux. Especially 14 | `Transformation` and related classes are likely to change in the near 15 | term. Please be on the lookout for bugs, issues and potential 16 | improvements! 17 | 18 | {{range .}}{{if .Documentation}}{{$name := .GoName}} 19 | ## {{.GoName}} 20 | {{.Documentation}} 21 | 22 | #### Attributes 23 | | Name | Type | Description | Required | 24 | | --- | --- | --- | --- | 25 | {{range .Attributes -}} 26 | | `{{.StarlarkName}}` | `{{.DocType}}` | {{.Documentation}} | {{if .IsRequired}}**Y**{{else}}N{{end}} | 27 | {{end}} 28 | {{range $i, $code := .Examples -}} 29 | #### Example 30 | ``` 31 | {{$code}} 32 | ``` 33 | ![](img/widget_{{$name}}_{{$i}}.gif) 34 | {{end}} 35 | {{end}}{{end}} 36 | -------------------------------------------------------------------------------- /runtime/gen/docs/render.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Widgets! 4 | 5 | Pixlet comes with a number of built-in _Widgets_. These are used to 6 | describe how data should be laid out, presented and finally rendered 7 | to an image file. 8 | 9 | The easiest way to get started is probably to take a look at some of 10 | the [examples](../examples/), and then refer to the detailed Widget 11 | documentation (this document) when the need arises. 12 | 13 | A quick note about colors. When specifying colors, use a CSS-like 14 | hexdecimal color specification. Pixlet supports "#rgb", "#rrggbb", 15 | "#rgba", and "#rrggbbaa" color specifications. 16 | 17 | For animated widgets like Marquee, you can also call `frame_count()` 18 | to work out how many frames are required to display the whole 19 | animation. You can also call `size()` on dynamically-sized widgets 20 | like Text to get the width and height. 21 | 22 | {{range .}}{{if .Documentation}}{{$name := .GoName}} 23 | ## {{.GoName}} 24 | {{.Documentation}} 25 | 26 | #### Attributes 27 | | Name | Type | Description | Required | 28 | | --- | --- | --- | --- | 29 | {{range .Attributes -}} 30 | | `{{.StarlarkName}}` | `{{.DocType}}` | {{.Documentation}} | {{if .IsRequired}}**Y**{{else}}N{{end}} | 31 | {{end}} 32 | {{range $i, $code := .Examples -}} 33 | #### Example 34 | ``` 35 | {{$code}} 36 | ``` 37 | ![](img/widget_{{$name}}_{{$i}}.gif) 38 | {{end}} 39 | {{end}}{{end}} 40 | -------------------------------------------------------------------------------- /runtime/gen/header/animation.tmpl: -------------------------------------------------------------------------------- 1 | package animation_runtime 2 | 3 | // Code generated by runtime/gen. DO NOT EDIT. 4 | 5 | 6 | import ( 7 | "fmt" 8 | "sync" 9 | 10 | "github.com/mitchellh/hashstructure/v2" 11 | "go.starlark.net/starlark" 12 | "go.starlark.net/starlarkstruct" 13 | 14 | "tidbyt.dev/pixlet/render" 15 | "tidbyt.dev/pixlet/render/animation" 16 | "tidbyt.dev/pixlet/runtime/modules/render_runtime" 17 | ) 18 | 19 | type AnimationModule struct { 20 | once sync.Once 21 | module starlark.StringDict 22 | } 23 | 24 | var animationModule = AnimationModule{} 25 | 26 | func LoadAnimationModule() (starlark.StringDict, error) { 27 | animationModule.once.Do(func() { 28 | animationModule.module = starlark.StringDict{ 29 | "animation": &starlarkstruct.Module{ 30 | Name: "render", 31 | Members: starlark.StringDict{ 32 | {{range .}} 33 | "{{.GoName}}": starlark.NewBuiltin("{{.GoName}}", new{{.GoName}}), 34 | {{end}} 35 | }, 36 | }, 37 | } 38 | }) 39 | 40 | return animationModule.module, nil 41 | } 42 | -------------------------------------------------------------------------------- /runtime/gen/header/render.tmpl: -------------------------------------------------------------------------------- 1 | package render_runtime 2 | 3 | // Code generated by runtime/gen. DO NOT EDIT. 4 | 5 | 6 | import ( 7 | "fmt" 8 | "sync" 9 | 10 | "github.com/mitchellh/hashstructure/v2" 11 | "go.starlark.net/starlark" 12 | "go.starlark.net/starlarkstruct" 13 | 14 | "tidbyt.dev/pixlet/render" 15 | ) 16 | 17 | type RenderModule struct { 18 | once sync.Once 19 | module starlark.StringDict 20 | } 21 | 22 | var renderModule = RenderModule{} 23 | 24 | func LoadRenderModule() (starlark.StringDict, error) { 25 | renderModule.once.Do(func() { 26 | fontList := render.GetFontList() 27 | fnt := starlark.NewDict(len(fontList)) 28 | for _, name := range fontList { 29 | fnt.SetKey(starlark.String(name), starlark.String(name)) 30 | } 31 | fnt.Freeze() 32 | 33 | renderModule.module = starlark.StringDict{ 34 | "render": &starlarkstruct.Module{ 35 | Name: "render", 36 | Members: starlark.StringDict{ 37 | "fonts": fnt, 38 | {{range .}} 39 | "{{.GoName}}": starlark.NewBuiltin("{{.GoName}}", new{{.GoName}}), 40 | {{end}} 41 | }, 42 | }, 43 | } 44 | }) 45 | 46 | return renderModule.module, nil 47 | } 48 | 49 | type Rootable interface { 50 | AsRenderRoot() render.Root 51 | } 52 | 53 | type Widget interface { 54 | AsRenderWidget() render.Widget 55 | } 56 | -------------------------------------------------------------------------------- /runtime/generate.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | //go:generate go run gen/main.go 4 | -------------------------------------------------------------------------------- /runtime/modules/animation_runtime/curve.go: -------------------------------------------------------------------------------- 1 | package animation_runtime 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.starlark.net/starlark" 7 | 8 | "tidbyt.dev/pixlet/render/animation" 9 | ) 10 | 11 | func CurveFromStarlark(value starlark.Value) (animation.Curve, error) { 12 | if str, ok := value.(starlark.String); ok { 13 | if str.Len() == 0 { 14 | return animation.LinearCurve{}, nil 15 | } else if curve, err := animation.ParseCurve(str.GoString()); err == nil { 16 | return curve, nil 17 | } else { 18 | return animation.LinearCurve{}, fmt.Errorf("curve is not a valid curve string: %s", str.GoString()) 19 | } 20 | } 21 | 22 | if fn, ok := value.(*starlark.Function); ok { 23 | if fn.NumParams() != 1 || fn.NumKwonlyParams() != 0 { 24 | return animation.LinearCurve{}, fmt.Errorf("invalid number of parameters to curve function: %s", fn.String()) 25 | } 26 | 27 | return animation.CustomCurve{Function: fn}, nil 28 | } 29 | 30 | return animation.LinearCurve{}, nil 31 | } 32 | -------------------------------------------------------------------------------- /runtime/modules/animation_runtime/percentage.go: -------------------------------------------------------------------------------- 1 | package animation_runtime 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.starlark.net/starlark" 7 | 8 | "tidbyt.dev/pixlet/render/animation" 9 | ) 10 | 11 | func PercentageFromStarlark(value starlark.Value) (animation.Percentage, error) { 12 | if val, ok := starlark.AsFloat(value); ok { 13 | if 0.0 <= val && val <= 1.0 { 14 | return animation.Percentage{val}, nil 15 | } else { 16 | return animation.Percentage{}, fmt.Errorf("invalid range for percentage: %f (expected number in range [0.0, 1.0])", val) 17 | } 18 | } 19 | 20 | return animation.Percentage{}, fmt.Errorf("invalid type for percentage: %s (expected number in range [0.0, 1.0])", value.Type()) 21 | } 22 | -------------------------------------------------------------------------------- /runtime/modules/hmac/hmac_test.go: -------------------------------------------------------------------------------- 1 | package hmac_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var hmacSource = ` 12 | load("hmac.star", "hmac") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | # Assert. 19 | 20 | assert(hmac.md5("secret", "helloworld") == "8bd4df4530c3c2cafabf6986740e44bd") 21 | assert(hmac.sha1("secret", "helloworld") == "e92eb69939a8b8c9843a75296714af611c73fb53") 22 | assert(hmac.sha256("secret", "helloworld") == "7a7c2bf41973489be3b318ad2f16c75fc875c340deecb12a3f79b28bb7135c97") 23 | 24 | def main(): 25 | return [] 26 | ` 27 | 28 | func TestHmac(t *testing.T) { 29 | app, err := runtime.NewApplet("hmac_test.star", []byte(hmacSource)) 30 | assert.NoError(t, err) 31 | assert.NotNil(t, app) 32 | 33 | screens, err := app.Run(context.Background()) 34 | assert.NoError(t, err) 35 | assert.NotNil(t, screens) 36 | } 37 | -------------------------------------------------------------------------------- /runtime/modules/qrcode/qrcode_test.go: -------------------------------------------------------------------------------- 1 | package qrcode_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var qrCodeSource = ` 12 | load("qrcode.star", "qrcode") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | 19 | url = "https://tidbyt.com?utm_source=pixlet_example" 20 | code = qrcode.generate( 21 | url = url, 22 | size = "large", 23 | color = "#fff", 24 | background = "#000", 25 | ) 26 | 27 | def main(): 28 | return [] 29 | ` 30 | 31 | func TestQRCode(t *testing.T) { 32 | app, err := runtime.NewApplet("test.star", []byte(qrCodeSource)) 33 | assert.NoError(t, err) 34 | 35 | screens, err := app.Run(context.Background()) 36 | assert.NoError(t, err) 37 | assert.NotNil(t, screens) 38 | } 39 | -------------------------------------------------------------------------------- /runtime/modules/random/random_test.go: -------------------------------------------------------------------------------- 1 | package random_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "tidbyt.dev/pixlet/runtime" 10 | ) 11 | 12 | var randomSrc = ` 13 | load("random.star", "random") 14 | 15 | min = 100 16 | max = 120 17 | 18 | def test_number(): 19 | for x in range(0, 300): 20 | num = random.number(min, max) 21 | if num < min: 22 | fail("random number less than min") 23 | if num > max: 24 | fail("random number greater than max") 25 | 26 | def test_seed(): 27 | random.seed(4711) 28 | sequence = [random.number(0, 1 << 20) for _ in range(500)] 29 | 30 | random.seed(4711) # same seed 31 | for i in range(len(sequence)): 32 | if sequence[i] != random.number(0, 1 << 20): 33 | fail("sequence mismatch despite identical seed") 34 | 35 | random.seed(4712) # different seed 36 | different = 0 37 | for i in range(len(sequence)): 38 | if sequence[i] != random.number(0, 1 << 20): 39 | different += 1 40 | if not different: 41 | fail("sequences identical despite different seeds") 42 | 43 | test_number() 44 | test_seed() 45 | 46 | def main(): 47 | return [] 48 | ` 49 | 50 | func TestRandom(t *testing.T) { 51 | app, err := runtime.NewApplet("random_test.star", []byte(randomSrc)) 52 | require.NoError(t, err) 53 | 54 | screens, err := app.Run(context.Background()) 55 | require.NoError(t, err) 56 | assert.NotNil(t, screens) 57 | } 58 | -------------------------------------------------------------------------------- /runtime/modules/starlarkhttp/testdata/test.star: -------------------------------------------------------------------------------- 1 | load("assert.star", "assert") 2 | load("http.star", "http") 3 | 4 | res_1 = http.get(test_server_url, params = {"a": "b", "c": "d"}) 5 | assert.eq(res_1.url, test_server_url + "?a=b&c=d") 6 | assert.eq(res_1.status_code, 200) 7 | assert.eq(res_1.body(), '{"hello":"world"}') 8 | assert.eq(res_1.json(), {"hello": "world"}) 9 | 10 | assert.eq(res_1.headers, {"Date": "Mon, 01 Jun 2000 00:00:00 GMT", "Content-Length": "17", "Content-Type": "text/plain; charset=utf-8"}) 11 | 12 | res_2 = http.get(test_server_url) 13 | assert.eq(res_2.json()["hello"], "world") 14 | 15 | headers = {"foo": "bar"} 16 | http.post(test_server_url, json_body = {"a": "b", "c": "d"}, headers = headers) 17 | http.post(test_server_url, form_body = {"a": "b", "c": "d"}) 18 | -------------------------------------------------------------------------------- /runtime/modules/sunrise/sunrise_test.go: -------------------------------------------------------------------------------- 1 | package sunrise_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var sunSource = ` 12 | load("time.star", "time") 13 | load("sunrise.star", "sunrise") 14 | 15 | def assert(success, message=None): 16 | if not success: 17 | fail(message or "assertion failed") 18 | 19 | def abs(x): 20 | if x > 0: 21 | return x 22 | return -x 23 | 24 | # Setup. 25 | format = "2006-01-02T15:04:05" 26 | input = time.parse_time("2022-01-15T22:40:24", format = format) 27 | expectedRise = time.parse_time("2022-01-15T12:17:29", format = format) 28 | expectedSet = time.parse_time("2022-01-15T21:52:30", format = format) 29 | lat = 40.6781784 30 | lng = -73.9441579 31 | 32 | # Sunrise occurs when center of the sun is 50 arc minutes below horizon 33 | # due to atmospheric refraction and the angular distance to the top. 34 | # https://en.wikipedia.org/wiki/Sunrise#Angle 35 | sunriseElevation = -50.0 / 60.0 36 | 37 | # Call methods. 38 | rise = sunrise.sunrise(lat, lng, input) 39 | set = sunrise.sunset(lat, lng, input) 40 | elevation = sunrise.elevation(lat, lng, expectedSet) 41 | morning, evening = sunrise.elevation_time(lat, lng, sunriseElevation, input) 42 | 43 | # Assert. 44 | assert(rise == expectedRise) 45 | assert(set == expectedSet) 46 | assert(abs(elevation - sunriseElevation) < 0.005) 47 | assert(abs(expectedRise.unix - morning.unix) < 2) 48 | assert(abs(evening.unix - expectedSet.unix) < 2) 49 | 50 | def main(): 51 | return [] 52 | ` 53 | 54 | func TestSunrise(t *testing.T) { 55 | app, err := runtime.NewApplet("sun.star", []byte(sunSource)) 56 | assert.NoError(t, err) 57 | 58 | screens, err := app.Run(context.Background()) 59 | assert.NoError(t, err) 60 | assert.NotNil(t, screens) 61 | } 62 | -------------------------------------------------------------------------------- /runtime/modules/xpath/xpath_test.go: -------------------------------------------------------------------------------- 1 | package xpath_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "tidbyt.dev/pixlet/runtime" 10 | ) 11 | 12 | func TestXPath(t *testing.T) { 13 | src := ` 14 | load("render.star", r="render") 15 | load("xpath.star", "xpath") 16 | 17 | def main(): 18 | xml = """ 19 | 20 | 1337 21 | 4711 22 | 23 | 999 24 | 888 25 | 26 | 27 | 777 28 | 29 | 30 | """ 31 | 32 | d = xpath.loads(xml) 33 | 34 | t = d.query("/foo/bar") 35 | if t != "1337": 36 | fail(t) 37 | 38 | t = d.query_all("/foo/bar") 39 | if len(t) != 2: 40 | fail(len(t)) 41 | if t[0] != "1337": 42 | fail(t[0]) 43 | if t[1] != "4711": 44 | fail(t[1]) 45 | 46 | t = d.query("/foo/doesntexist") 47 | if t != None: 48 | fail(t) 49 | 50 | t = d.query_all("/foo/doesntexist") 51 | if len(t) != 0: 52 | fail(t) 53 | 54 | n = d.query_node("/foo/baz") 55 | t = n.query("/qux") 56 | if t != "999": 57 | fail(t) 58 | 59 | n = d.query_all_nodes("/foo/baz") 60 | if len(n) != 2: 61 | fail(len(n)) 62 | t = n[0].query_all("/qux") 63 | if len(t) != 2: 64 | fail(len(t)) 65 | if t[0] != "999": 66 | fail(t[0]) 67 | if t[1] != "888": 68 | fail(t[1]) 69 | t = n[1].query_all("/qux") 70 | if len(t) != 1: 71 | fail(len(t)) 72 | if t[0] != "777": 73 | fail(t[0]) 74 | 75 | return [r.Root(child=r.Text("1337"))] 76 | ` 77 | app, err := runtime.NewApplet("test.star", []byte(src)) 78 | require.NoError(t, err) 79 | screens, err := app.Run(context.Background()) 80 | require.NoError(t, err) 81 | assert.NotNil(t, screens) 82 | } 83 | -------------------------------------------------------------------------------- /runtime/testdata/httpcache.star: -------------------------------------------------------------------------------- 1 | """ 2 | Applet: Test App 3 | Summary: For Testing 4 | Description: It's an app for testing. 5 | Author: Test Dev 6 | """ 7 | 8 | load("assert.star", "assert") 9 | load("http.star", "http") 10 | load("render.star", "render") 11 | 12 | def main(config): 13 | resp = http.get( 14 | url = "https://example.com", 15 | ttl_seconds = 60, 16 | ) 17 | assert.eq(resp.headers.get("Tidbyt-Cache-Status"), "MISS") 18 | 19 | resp = http.get( 20 | url = "https://example.com", 21 | ttl_seconds = 3, 22 | ) 23 | assert.eq(resp.headers.get("Tidbyt-Cache-Status"), "HIT") 24 | 25 | resp = http.post( 26 | url = "https://example.com", 27 | ttl_seconds = 0, 28 | ) 29 | assert.eq(resp.headers.get("Tidbyt-Cache-Status"), "MISS") 30 | 31 | resp = http.post( 32 | url = "https://example.com", 33 | ttl_seconds = 60, 34 | ) 35 | assert.eq(resp.headers.get("Tidbyt-Cache-Status"), "HIT") 36 | 37 | resp = http.post( 38 | url = "https://example.com", 39 | ttl_seconds = 60, 40 | ) 41 | assert.eq(resp.headers.get("Tidbyt-Cache-Status"), "HIT") 42 | 43 | return render.Root( 44 | child = render.Box( 45 | width = 64, 46 | height = 32, 47 | ), 48 | ) 49 | -------------------------------------------------------------------------------- /schema/datetime_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var dateTimeSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | t = schema.DateTime( 19 | id = "event_name", 20 | name = "Event Name", 21 | desc = "The time of the event.", 22 | icon = "gear", 23 | ) 24 | 25 | assert(t.id == "event_name") 26 | assert(t.name == "Event Name") 27 | assert(t.desc == "The time of the event.") 28 | assert(t.icon == "gear") 29 | 30 | def main(): 31 | return [] 32 | ` 33 | 34 | func TestDateTime(t *testing.T) { 35 | app, err := runtime.NewApplet("date_time.star", []byte(dateTimeSource)) 36 | assert.NoError(t, err) 37 | 38 | screens, err := app.Run(context.Background()) 39 | assert.NoError(t, err) 40 | assert.NotNil(t, screens) 41 | } 42 | -------------------------------------------------------------------------------- /schema/dropdown_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var dropdownSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | options = [ 19 | schema.Option( 20 | display = "Green", 21 | value = "#00FF00", 22 | ), 23 | schema.Option( 24 | display = "Red", 25 | value = "#FF0000", 26 | ), 27 | ] 28 | 29 | s = schema.Dropdown( 30 | id = "colors", 31 | name = "Text Color", 32 | desc = "The color of text to be displayed.", 33 | icon = "brush", 34 | default = options[0].value, 35 | options = options, 36 | ) 37 | 38 | assert(s.id == "colors") 39 | assert(s.name == "Text Color") 40 | assert(s.desc == "The color of text to be displayed.") 41 | assert(s.icon == "brush") 42 | assert(s.default == "#00FF00") 43 | 44 | assert(s.options[0].display == "Green") 45 | assert(s.options[0].value == "#00FF00") 46 | 47 | assert(s.options[1].display == "Red") 48 | assert(s.options[1].value == "#FF0000") 49 | 50 | def main(): 51 | return [] 52 | ` 53 | 54 | func TestDropdown(t *testing.T) { 55 | app, err := runtime.NewApplet("dropdown.star", []byte(dropdownSource)) 56 | assert.NoError(t, err) 57 | 58 | screens, err := app.Run(context.Background()) 59 | assert.NoError(t, err) 60 | assert.NotNil(t, screens) 61 | } 62 | -------------------------------------------------------------------------------- /schema/generated.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mitchellh/hashstructure/v2" 7 | "go.starlark.net/starlark" 8 | ) 9 | 10 | type Generated struct { 11 | SchemaField 12 | } 13 | 14 | func newGenerated( 15 | thread *starlark.Thread, 16 | _ *starlark.Builtin, 17 | args starlark.Tuple, 18 | kwargs []starlark.Tuple, 19 | ) (starlark.Value, error) { 20 | var ( 21 | id starlark.String 22 | source starlark.String 23 | handler *starlark.Function 24 | ) 25 | 26 | if err := starlark.UnpackArgs( 27 | "Generated", 28 | args, kwargs, 29 | "source", &source, 30 | "handler", &handler, 31 | "id", &id, 32 | ); err != nil { 33 | return nil, fmt.Errorf("unpacking arguments for Generated: %s", err) 34 | } 35 | 36 | s := &Generated{} 37 | s.StarlarkHandler = handler 38 | s.Source = source.GoString() 39 | s.Handler = handler.Name() 40 | s.ID = id.GoString() 41 | s.SchemaField.Type = "generated" 42 | 43 | return s, nil 44 | } 45 | 46 | func (s *Generated) AsSchemaField() SchemaField { 47 | return s.SchemaField 48 | } 49 | 50 | func (s *Generated) AttrNames() []string { 51 | return []string{ 52 | "source", "handler", "id", 53 | } 54 | } 55 | 56 | func (s *Generated) Attr(name string) (starlark.Value, error) { 57 | switch name { 58 | case "source": 59 | return starlark.String(s.Source), nil 60 | 61 | case "handler": 62 | return s.StarlarkHandler, nil 63 | 64 | case "id": 65 | return starlark.String(s.ID), nil 66 | 67 | default: 68 | return nil, nil 69 | } 70 | } 71 | 72 | func (s *Generated) String() string { return "Generated(...)" } 73 | func (s *Generated) Type() string { return "Generated" } 74 | func (s *Generated) Freeze() {} 75 | func (s *Generated) Truth() starlark.Bool { return true } 76 | 77 | func (s *Generated) Hash() (uint32, error) { 78 | sum, err := hashstructure.Hash(s, hashstructure.FormatV2, nil) 79 | return uint32(sum), err 80 | } 81 | -------------------------------------------------------------------------------- /schema/generated_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var generatedSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | s = schema.Generated( 19 | id = "foo", 20 | source = "bar", 21 | handler = assert, 22 | ) 23 | 24 | assert(s.id == "foo") 25 | assert(s.source == "bar") 26 | assert(s.handler == assert) 27 | 28 | def main(): 29 | return [] 30 | ` 31 | 32 | func TestGenerated(t *testing.T) { 33 | app, err := runtime.NewApplet("generated.star", []byte(generatedSource)) 34 | assert.NoError(t, err) 35 | 36 | screens, err := app.Run(context.Background()) 37 | assert.NoError(t, err) 38 | assert.NotNil(t, screens) 39 | } 40 | -------------------------------------------------------------------------------- /schema/handler_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var handlerSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | def foobar(param): 19 | return "derp" 20 | 21 | h = schema.Handler( 22 | handler = foobar, 23 | type = schema.HandlerType.String, 24 | ) 25 | 26 | assert(h.handler == foobar) 27 | assert(h.type == schema.HandlerType.String) 28 | 29 | def main(): 30 | return [] 31 | ` 32 | 33 | func TestHandler(t *testing.T) { 34 | app, err := runtime.NewApplet("handler.star", []byte(handlerSource)) 35 | assert.NoError(t, err) 36 | 37 | screens, err := app.Run(context.Background()) 38 | assert.NoError(t, err) 39 | assert.NotNil(t, screens) 40 | } 41 | 42 | func TestHandlerBadParams(t *testing.T) { 43 | // Handler is a string 44 | app, err := runtime.NewApplet("text.star", []byte(` 45 | load("schema.star", "schema") 46 | 47 | def foobar(param): 48 | return "derp" 49 | 50 | h = schema.Handler( 51 | handler = "foobar", 52 | type = schema.HandlerType.String, 53 | ) 54 | 55 | def main(): 56 | return [] 57 | `)) 58 | assert.Error(t, err) 59 | assert.Nil(t, app) 60 | 61 | // Type is not valid 62 | app, err = runtime.NewApplet("text.star", []byte(` 63 | load("schema.star", "schema") 64 | 65 | def foobar(param): 66 | return "derp" 67 | 68 | h = schema.Handler( 69 | handler = foobar, 70 | type = 42, 71 | ) 72 | 73 | def main(): 74 | return [] 75 | `)) 76 | assert.Error(t, err) 77 | assert.Nil(t, app) 78 | } 79 | -------------------------------------------------------------------------------- /schema/location_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var locationSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | s = schema.Location( 19 | id = "location", 20 | name = "Location", 21 | desc = "Location for which to display time.", 22 | icon = "locationDot", 23 | ) 24 | 25 | assert(s.id == "location") 26 | assert(s.name == "Location") 27 | assert(s.desc == "Location for which to display time.") 28 | assert(s.icon == "locationDot") 29 | 30 | def main(): 31 | return [] 32 | ` 33 | 34 | func TestLocation(t *testing.T) { 35 | app, err := runtime.NewApplet("location.star", []byte(locationSource)) 36 | assert.NoError(t, err) 37 | 38 | screens, err := app.Run(context.Background()) 39 | assert.NoError(t, err) 40 | assert.NotNil(t, screens) 41 | } 42 | -------------------------------------------------------------------------------- /schema/locationbased_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var locationBasedSource = ` 12 | load("encoding/json.star", "json") 13 | load("schema.star", "schema") 14 | 15 | DEFAULT_LOCATION = """ 16 | { 17 | "lat": "40.6781784", 18 | "lng": "-73.9441579", 19 | "description": "Brooklyn, NY, USA", 20 | "locality": "Brooklyn", 21 | "place_id": "ChIJCSF8lBZEwokRhngABHRcdoI", 22 | "timezone": "America/New_York" 23 | } 24 | """ 25 | 26 | def assert(success, message = None): 27 | if not success: 28 | fail(message or "assertion failed") 29 | 30 | def get_stations(location): 31 | loc = json.decode(location) 32 | lat, lng = float(loc["lat"]), float(loc["lng"]) 33 | 34 | return [ 35 | schema.Option( 36 | display = "Grand Central", 37 | value = "abc123", 38 | ), 39 | schema.Option( 40 | display = "Penn Station", 41 | value = "xyz123", 42 | ), 43 | ] 44 | 45 | t = schema.LocationBased( 46 | id = "station", 47 | name = "Train Station", 48 | desc = "A list of train stations based on a location.", 49 | icon = "train", 50 | handler = get_stations, 51 | ) 52 | 53 | assert(t.id == "station") 54 | assert(t.name == "Train Station") 55 | assert(t.desc == "A list of train stations based on a location.") 56 | assert(t.icon == "train") 57 | assert(t.handler(DEFAULT_LOCATION)[0].display == "Grand Central") 58 | 59 | def main(): 60 | return [] 61 | 62 | ` 63 | 64 | func TestLocationBased(t *testing.T) { 65 | app, err := runtime.NewApplet("location_based.star", []byte(locationBasedSource)) 66 | assert.NoError(t, err) 67 | 68 | screens, err := app.Run(context.Background()) 69 | assert.NoError(t, err) 70 | assert.NotNil(t, screens) 71 | } 72 | -------------------------------------------------------------------------------- /schema/module_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var moduleSource = ` 12 | load("schema.star", "schema") 13 | 14 | def main(): 15 | return [] 16 | ` 17 | 18 | var schemaSource = ` 19 | load("schema.star", "schema") 20 | 21 | def assert(success, message=None): 22 | if not success: 23 | fail(message or "assertion failed") 24 | 25 | s = schema.Schema( 26 | version = "1", 27 | fields = [ 28 | schema.Toggle( 29 | id = "display_weather", 30 | name = "Display Weather", 31 | desc = "A toggle to determine if the weather should be displayed.", 32 | icon = "cloud", 33 | ), 34 | ], 35 | ) 36 | 37 | assert(s.version == "1") 38 | assert(s.fields[0].name == "Display Weather") 39 | 40 | def main(): 41 | return [] 42 | ` 43 | 44 | func TestStarlarkSchema(t *testing.T) { 45 | app, err := runtime.NewApplet("starlark.star", []byte(schemaSource)) 46 | assert.NoError(t, err) 47 | 48 | screens, err := app.Run(context.Background()) 49 | assert.NoError(t, err) 50 | assert.NotNil(t, screens) 51 | } 52 | func TestSchemaModuleLoads(t *testing.T) { 53 | app, err := runtime.NewApplet("source.star", []byte(moduleSource)) 54 | assert.NoError(t, err) 55 | 56 | screens, err := app.Run(context.Background()) 57 | assert.NoError(t, err) 58 | assert.NotNil(t, screens) 59 | } 60 | -------------------------------------------------------------------------------- /schema/notification_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "testing/fstest" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "tidbyt.dev/pixlet/runtime" 11 | ) 12 | 13 | var notificationSource = ` 14 | load("assert.star", "assert") 15 | load("schema.star", "schema") 16 | load("sound.mp3", "file") 17 | 18 | sounds = [ 19 | schema.Sound( 20 | id = "ding", 21 | title = "Ding!", 22 | file = file, 23 | ), 24 | 25 | ] 26 | 27 | s = schema.Notification( 28 | id = "notification1", 29 | name = "New message", 30 | desc = "A new message has arrived", 31 | icon = "message", 32 | sounds = sounds, 33 | builder = lambda: None, 34 | ) 35 | 36 | assert.eq(s.id, "notification1") 37 | assert.eq(s.name, "New message") 38 | assert.eq(s.desc, "A new message has arrived") 39 | assert.eq(s.icon, "message") 40 | 41 | assert.eq(s.sounds[0].id, "ding") 42 | assert.eq(s.sounds[0].title, "Ding!") 43 | assert.eq(s.sounds[0].file, file) 44 | 45 | def main(): 46 | return [] 47 | ` 48 | 49 | func TestNotification(t *testing.T) { 50 | vfs := fstest.MapFS{ 51 | "sound.mp3": &fstest.MapFile{Data: []byte("sound data")}, 52 | "notification.star": &fstest.MapFile{Data: []byte(notificationSource)}, 53 | } 54 | app, err := runtime.NewAppletFromFS("sound", vfs) 55 | assert.NoError(t, err) 56 | 57 | screens, err := app.Run(context.Background()) 58 | assert.NoError(t, err) 59 | assert.NotNil(t, screens) 60 | } 61 | -------------------------------------------------------------------------------- /schema/oauth2_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var oauth2Source = ` 12 | load("encoding/json.star", "json") 13 | load("schema.star", "schema") 14 | 15 | def assert(success, message = None): 16 | if not success: 17 | fail(message or "assertion failed") 18 | 19 | def oauth_handler(params): 20 | params = json.decode(params) 21 | return "foobar123" 22 | 23 | t = schema.OAuth2( 24 | id = "auth", 25 | name = "GitHub", 26 | desc = "Connect your GitHub account.", 27 | icon = "github", 28 | handler = oauth_handler, 29 | client_id = "the-oauth2-client-id", 30 | authorization_endpoint = "https://example.com/", 31 | scopes = [ 32 | "read:user", 33 | ], 34 | ) 35 | 36 | assert(t.id == "auth") 37 | assert(t.name == "GitHub") 38 | assert(t.desc == "Connect your GitHub account.") 39 | assert(t.icon == "github") 40 | assert(t.handler("{}") == "foobar123") 41 | assert(t.client_id == "the-oauth2-client-id") 42 | assert(t.authorization_endpoint == "https://example.com/") 43 | assert(t.scopes == ["read:user"]) 44 | 45 | def main(): 46 | return [] 47 | 48 | ` 49 | 50 | func TestOAuth2(t *testing.T) { 51 | app, err := runtime.NewApplet("oauth2.star", []byte(oauth2Source)) 52 | assert.NoError(t, err) 53 | 54 | screens, err := app.Run(context.Background()) 55 | assert.NoError(t, err) 56 | assert.NotNil(t, screens) 57 | } 58 | -------------------------------------------------------------------------------- /schema/option.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mitchellh/hashstructure/v2" 7 | "go.starlark.net/starlark" 8 | ) 9 | 10 | type Option struct { 11 | SchemaOption 12 | } 13 | 14 | func newOption( 15 | thread *starlark.Thread, 16 | _ *starlark.Builtin, 17 | args starlark.Tuple, 18 | kwargs []starlark.Tuple, 19 | ) (starlark.Value, error) { 20 | var ( 21 | display starlark.String 22 | value starlark.String 23 | ) 24 | 25 | if err := starlark.UnpackArgs( 26 | "Option", 27 | args, kwargs, 28 | "display", &display, 29 | "value", &value, 30 | ); err != nil { 31 | return nil, fmt.Errorf("unpacking arguments for Option: %s", err) 32 | } 33 | 34 | s := &Option{} 35 | s.SchemaOption.Text = display.GoString() 36 | s.SchemaOption.Display = display.GoString() 37 | s.SchemaOption.Value = value.GoString() 38 | 39 | return s, nil 40 | } 41 | 42 | func (s *Option) AsSchemaOption() SchemaOption { 43 | return s.SchemaOption 44 | } 45 | 46 | func (s *Option) AttrNames() []string { 47 | return []string{ 48 | "display", "value", 49 | } 50 | } 51 | 52 | func (s *Option) Attr(name string) (starlark.Value, error) { 53 | switch name { 54 | 55 | case "display": 56 | return starlark.String(s.Text), nil 57 | 58 | case "value": 59 | return starlark.String(s.Value), nil 60 | 61 | default: 62 | return nil, nil 63 | } 64 | } 65 | 66 | func (s *Option) String() string { return "Option(...)" } 67 | func (s *Option) Type() string { return "Option" } 68 | func (s *Option) Freeze() {} 69 | func (s *Option) Truth() starlark.Bool { return true } 70 | 71 | func (s *Option) Hash() (uint32, error) { 72 | sum, err := hashstructure.Hash(s, hashstructure.FormatV2, nil) 73 | return uint32(sum), err 74 | } 75 | -------------------------------------------------------------------------------- /schema/option_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var optionSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | s = schema.Option( 19 | display = "Green", 20 | value = "#00FF00", 21 | ) 22 | 23 | assert(s.display == "Green") 24 | assert(s.value == "#00FF00") 25 | 26 | def main(): 27 | return [] 28 | ` 29 | 30 | func TestOption(t *testing.T) { 31 | app, err := runtime.NewApplet("option.star", []byte(optionSource)) 32 | assert.NoError(t, err) 33 | 34 | screens, err := app.Run(context.Background()) 35 | assert.NoError(t, err) 36 | assert.NotNil(t, screens) 37 | } 38 | -------------------------------------------------------------------------------- /schema/photoselect_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var photoSelectSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | t = schema.PhotoSelect( 19 | id = "photo", 20 | name = "Add Photo", 21 | desc = "A photo.", 22 | icon = "gear", 23 | ) 24 | 25 | assert(t.id == "photo") 26 | assert(t.name == "Add Photo") 27 | assert(t.desc == "A photo.") 28 | assert(t.icon == "gear") 29 | 30 | def main(): 31 | return [] 32 | ` 33 | 34 | func TestPhotoSelect(t *testing.T) { 35 | app, err := runtime.NewApplet("photo_select.star", []byte(photoSelectSource)) 36 | assert.NoError(t, err) 37 | 38 | screens, err := app.Run(context.Background()) 39 | assert.NoError(t, err) 40 | assert.NotNil(t, screens) 41 | } 42 | -------------------------------------------------------------------------------- /schema/sound.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mitchellh/hashstructure/v2" 7 | "go.starlark.net/starlark" 8 | 9 | "tidbyt.dev/pixlet/runtime/modules/file" 10 | ) 11 | 12 | type Sound struct { 13 | SchemaSound 14 | file *file.File 15 | } 16 | 17 | func newSound( 18 | thread *starlark.Thread, 19 | _ *starlark.Builtin, 20 | args starlark.Tuple, 21 | kwargs []starlark.Tuple, 22 | ) (starlark.Value, error) { 23 | var ( 24 | id starlark.String 25 | title starlark.String 26 | file *file.File 27 | ) 28 | 29 | if err := starlark.UnpackArgs( 30 | "Sound", 31 | args, kwargs, 32 | "id", &id, 33 | "title", &title, 34 | "file", &file, 35 | ); err != nil { 36 | return nil, fmt.Errorf("unpacking arguments for Sound: %s", err) 37 | } 38 | 39 | s := &Sound{file: file} 40 | s.ID = id.GoString() 41 | s.Title = title.GoString() 42 | s.Path = file.Path 43 | 44 | return s, nil 45 | } 46 | 47 | func (s *Sound) AsSchemaSound() SchemaSound { 48 | return s.SchemaSound 49 | } 50 | 51 | func (s *Sound) AttrNames() []string { 52 | return []string{"id", "title", "file"} 53 | } 54 | 55 | func (s *Sound) Attr(name string) (starlark.Value, error) { 56 | switch name { 57 | case "id": 58 | return starlark.String(s.ID), nil 59 | 60 | case "title": 61 | return starlark.String(s.Title), nil 62 | 63 | case "file": 64 | return s.file, nil 65 | 66 | default: 67 | return nil, nil 68 | } 69 | } 70 | 71 | func (s *Sound) String() string { return "Sound(...)" } 72 | func (s *Sound) Type() string { return "Sound" } 73 | func (s *Sound) Freeze() {} 74 | func (s *Sound) Truth() starlark.Bool { return true } 75 | 76 | func (s *Sound) Hash() (uint32, error) { 77 | sum, err := hashstructure.Hash(s, hashstructure.FormatV2, nil) 78 | return uint32(sum), err 79 | } 80 | -------------------------------------------------------------------------------- /schema/sound_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "testing/fstest" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "tidbyt.dev/pixlet/runtime" 10 | ) 11 | 12 | var soundSource = ` 13 | load("schema.star", "schema") 14 | load("sound.mp3", "file") 15 | 16 | def assert(success, message=None): 17 | if not success: 18 | fail(message or "assertion failed") 19 | 20 | s = schema.Sound( 21 | id = "sound1", 22 | title = "Sneezing Elephant", 23 | file = file, 24 | ) 25 | 26 | assert(s.id == "sound1") 27 | assert(s.title == "Sneezing Elephant") 28 | assert(s.file == file) 29 | assert(s.file.readall() == "sound data") 30 | 31 | def main(): 32 | return [] 33 | ` 34 | 35 | func TestSound(t *testing.T) { 36 | vfs := fstest.MapFS{ 37 | "sound.mp3": &fstest.MapFile{Data: []byte("sound data")}, 38 | "sound.star": &fstest.MapFile{Data: []byte(soundSource)}, 39 | } 40 | app, err := runtime.NewAppletFromFS("sound", vfs) 41 | assert.NoError(t, err) 42 | 43 | screens, err := app.Run(context.Background()) 44 | assert.NoError(t, err) 45 | assert.NotNil(t, screens) 46 | } 47 | -------------------------------------------------------------------------------- /schema/text_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var textSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | s = schema.Text( 19 | id = "screen_name", 20 | name = "Screen Name", 21 | desc = "A text entry for your screen name.", 22 | icon = "user", 23 | default = "foo", 24 | ) 25 | 26 | assert(s.id == "screen_name") 27 | assert(s.name == "Screen Name") 28 | assert(s.desc == "A text entry for your screen name.") 29 | assert(s.icon == "user") 30 | assert(s.default == "foo") 31 | 32 | def main(): 33 | return [] 34 | ` 35 | 36 | func TestText(t *testing.T) { 37 | app, err := runtime.NewApplet("text.star", []byte(textSource)) 38 | assert.NoError(t, err) 39 | 40 | screens, err := app.Run(context.Background()) 41 | assert.NoError(t, err) 42 | assert.NotNil(t, screens) 43 | } 44 | -------------------------------------------------------------------------------- /schema/toggle_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var toggleSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message=None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | t = schema.Toggle( 19 | id = "display_weather", 20 | name = "Display Weather", 21 | desc = "A toggle to determine if the weather should be displayed.", 22 | icon = "cloud", 23 | default = True, 24 | ) 25 | 26 | assert(t.id == "display_weather") 27 | assert(t.name == "Display Weather") 28 | assert(t.desc == "A toggle to determine if the weather should be displayed.") 29 | assert(t.icon == "cloud") 30 | assert(t.default == True) 31 | 32 | def main(): 33 | return [] 34 | ` 35 | 36 | func TestToggle(t *testing.T) { 37 | app, err := runtime.NewApplet("toggle.star", []byte(toggleSource)) 38 | assert.NoError(t, err) 39 | 40 | screens, err := app.Run(context.Background()) 41 | assert.NoError(t, err) 42 | assert.NotNil(t, screens) 43 | } 44 | -------------------------------------------------------------------------------- /schema/typeahead_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/runtime" 9 | ) 10 | 11 | var typeaheadSource = ` 12 | load("schema.star", "schema") 13 | 14 | def assert(success, message = None): 15 | if not success: 16 | fail(message or "assertion failed") 17 | 18 | def search(pattern): 19 | return [ 20 | schema.Option( 21 | display = "Grand Central", 22 | value = "abc123", 23 | ), 24 | schema.Option( 25 | display = "Penn Station", 26 | value = "xyz123", 27 | ), 28 | ] 29 | 30 | t = schema.Typeahead( 31 | id = "search", 32 | name = "Search", 33 | desc = "A list of items that match search.", 34 | icon = "gear", 35 | handler = search, 36 | ) 37 | 38 | assert(t.id == "search") 39 | assert(t.name == "Search") 40 | assert(t.desc == "A list of items that match search.") 41 | assert(t.icon == "gear") 42 | assert(t.handler("")[0].display == "Grand Central") 43 | 44 | def main(): 45 | return [] 46 | 47 | ` 48 | 49 | func TestTypeahead(t *testing.T) { 50 | app, err := runtime.NewApplet("typeahead.star", []byte(typeaheadSource)) 51 | assert.NoError(t, err) 52 | 53 | screens, err := app.Run(context.Background()) 54 | assert.NoError(t, err) 55 | assert.NotNil(t, screens) 56 | } 57 | -------------------------------------------------------------------------------- /scripts/build-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z "$RELEASE_ARCHS" ]; then 4 | echo "Please set RELEASE_ARCHS" 5 | exit 1 6 | fi 7 | 8 | if [ -z "$RELEASE_PLATFORM" ]; then 9 | echo "Please set RELEASE_PLATFORM" 10 | exit 1 11 | fi 12 | 13 | for ARCH in $RELEASE_ARCHS 14 | do 15 | if [[ $ARCH == *arm* ]]; then 16 | RELEASE_ARCH=arm64 17 | else 18 | RELEASE_ARCH=amd64 19 | fi 20 | 21 | echo "Building ${RELEASE_PLATFORM}_${RELEASE_ARCH}" 22 | 23 | if [[ $ARCH == "linux-arm64" ]]; then 24 | echo "linux-arm64" 25 | CC=aarch64-linux-gnu-gcc CGO_LDFLAGS="-Wl,-Bstatic -lwebp -lwebpdemux -lwebpmux -Wl,-Bdynamic" CGO_ENABLED=1 GOOS=$RELEASE_PLATFORM GOARCH=$RELEASE_ARCH go build -ldflags="-X 'tidbyt.dev/pixlet/cmd.Version=${PIXLET_VERSION}'" -o build/${RELEASE_PLATFORM}_${RELEASE_ARCH}/pixlet tidbyt.dev/pixlet 26 | elif [[ $ARCH == "linux-amd64" ]]; then 27 | echo "linux-amd64" 28 | CGO_ENABLED=1 GOOS=$RELEASE_PLATFORM GOARCH=$RELEASE_ARCH go build -ldflags="-s -extldflags=-static -X 'tidbyt.dev/pixlet/cmd.Version=${PIXLET_VERSION}'" -o build/${RELEASE_PLATFORM}_${RELEASE_ARCH}/pixlet tidbyt.dev/pixlet 29 | elif [[ $ARCH == "windows-amd64" ]]; then 30 | echo "windows-amd64" 31 | go build -ldflags="-s -extldflags=-static -X 'tidbyt.dev/pixlet/cmd.Version=${PIXLET_VERSION}'" -tags timetzdata -o build/${RELEASE_PLATFORM}_${RELEASE_ARCH}/pixlet.exe tidbyt.dev/pixlet 32 | else 33 | echo "other" 34 | CGO_CFLAGS="-I/tmp/${LIBWEBP_VERSION}/${ARCH}/include" CGO_LDFLAGS="-L/tmp/${LIBWEBP_VERSION}/${ARCH}/lib" CGO_ENABLED=1 GOOS=$RELEASE_PLATFORM GOARCH=$RELEASE_ARCH go build -ldflags="-X 'tidbyt.dev/pixlet/cmd.Version=${PIXLET_VERSION}'" -o build/${RELEASE_PLATFORM}_${RELEASE_ARCH}/pixlet tidbyt.dev/pixlet 35 | fi 36 | 37 | echo "Built ./build/${RELEASE_PLATFORM}_${RELEASE_ARCH}/pixlet successfully" 38 | done 39 | -------------------------------------------------------------------------------- /scripts/fetch-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z "$LIBWEBP_VERSION" ]; then 4 | echo "Please set LIBWEBP_VERSION" 5 | exit 1 6 | fi 7 | 8 | if [ -z "$RELEASE_ARCHS" ]; then 9 | echo "Please set LIBWEBP_ARCHS" 10 | exit 1 11 | fi 12 | 13 | rm -rf "/tmp/${LIBWEBP_VERSION}" 14 | mkdir -p "/tmp/$LIBWEBP_VERSION" 15 | pushd "/tmp/$LIBWEBP_VERSION" > /dev/null 16 | 17 | echo "Fetching WebP Binaries" 18 | for ARCH in $RELEASE_ARCHS 19 | do 20 | if [[ $ARCH == windows* ]]; then 21 | curl -sLO "https://storage.googleapis.com/downloads.webmproject.org/releases/webp/${LIBWEBP_VERSION}-${ARCH}.zip" 22 | unzip -q "${LIBWEBP_VERSION}-${ARCH}.zip" -d "${ARCH}" 23 | else 24 | curl -sLO "https://storage.googleapis.com/downloads.webmproject.org/releases/webp/${LIBWEBP_VERSION}-${ARCH}.tar.gz" 25 | tar -xf "${LIBWEBP_VERSION}-${ARCH}.tar.gz" 26 | mv "${LIBWEBP_VERSION}-${ARCH}" "${ARCH}" 27 | fi 28 | 29 | echo "Fetched /tmp/${LIBWEBP_VERSION}/${ARCH} successfully" 30 | done 31 | 32 | popd > /dev/null 33 | -------------------------------------------------------------------------------- /scripts/release-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | export RELEASE_ARCHS="linux-amd64 linux-arm64" 6 | export RELEASE_PLATFORM="linux" 7 | 8 | source scripts/build-release.sh 9 | -------------------------------------------------------------------------------- /scripts/release-macos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | export RELEASE_ARCHS="mac-arm64 mac-x86-64" 6 | export RELEASE_PLATFORM="darwin" 7 | 8 | source scripts/set-libwebp-version.sh 9 | source scripts/fetch-deps.sh 10 | source scripts/build-release.sh 11 | -------------------------------------------------------------------------------- /scripts/release-windows.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | export RELEASE_ARCHS="windows-amd64" 6 | export RELEASE_PLATFORM="windows" 7 | 8 | source scripts/build-release.sh 9 | -------------------------------------------------------------------------------- /scripts/set-executable.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | for dist in "darwin_amd64" "linux_amd64" "darwin_arm64" "linux_arm64"; do 6 | chmod +x "build/$dist/pixlet" 7 | done -------------------------------------------------------------------------------- /scripts/set-libwebp-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export LIBWEBP_VERSION="libwebp-1.2.2-rc1" 4 | -------------------------------------------------------------------------------- /scripts/setup-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | dpkg --add-architecture arm64 6 | 7 | cat < /etc/apt/sources.list 8 | deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted 9 | deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates main restricted 10 | deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy universe 11 | deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates universe 12 | deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy multiverse 13 | deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates multiverse 14 | deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-backports main restricted universe multiverse 15 | deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security main restricted 16 | deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security universe 17 | deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security multiverse 18 | 19 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted 20 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted 21 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy universe 22 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates universe 23 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy multiverse 24 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates multiverse 25 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse 26 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted 27 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security universe 28 | deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security multiverse 29 | EOT 30 | 31 | apt-get update 32 | apt-get install -y \ 33 | libwebp-dev \ 34 | libwebp-dev:arm64 \ 35 | crossbuild-essential-arm64 -------------------------------------------------------------------------------- /scripts/setup-macos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | brew install \ 6 | webp \ 7 | gnupg -------------------------------------------------------------------------------- /server/browser/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/server/browser/favicon.png -------------------------------------------------------------------------------- /server/browser/preview-mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/server/browser/preview-mask.png -------------------------------------------------------------------------------- /server/browser/serve.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package browser 4 | 5 | import ( 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | func (b *Browser) serveHTTP() error { 11 | log.Printf("listening at http://%s\n", b.addr) 12 | return http.ListenAndServe(b.addr, b.r) 13 | } 14 | -------------------------------------------------------------------------------- /server/fanout/event.go: -------------------------------------------------------------------------------- 1 | package fanout 2 | 3 | const ( 4 | // EventTypeImage is used to signal what type of message we are sending over 5 | // the socket. 6 | EventTypeImage = "img" 7 | 8 | // EventTypeSchema is used to signal that the schema for a given app has 9 | // changed. 10 | EventTypeSchema = "schema" 11 | 12 | // EventTypeErr is used to signal there was an error encountered rendering 13 | // the image. 14 | EventTypeErr = "error" 15 | ) 16 | 17 | // WebsocketEvent is a structure used to send messages over the socket. 18 | type WebsocketEvent struct { 19 | // Message is the contents of the message. This is a webp or gif, base64 encoded. 20 | Message string `json:"message"` 21 | 22 | // ImageType indicates whether the Message is webp or gif image. 23 | ImageType string `json:"img_type"` 24 | 25 | // Type is the type of message we are sending over the socket. 26 | Type string `json:"type"` 27 | } 28 | -------------------------------------------------------------------------------- /server/fanout/fanout.go: -------------------------------------------------------------------------------- 1 | package fanout 2 | 3 | // Fanout provides a structure for broadcasting messages to registered clients 4 | // when an update comes in on a go channel. 5 | type Fanout struct { 6 | broadcast chan WebsocketEvent 7 | quit chan bool 8 | register chan *Client 9 | unregister chan *Client 10 | } 11 | 12 | // NewFanout creates a new Fanout structure and runs the main loop. 13 | func NewFanout() *Fanout { 14 | fo := &Fanout{ 15 | broadcast: make(chan WebsocketEvent, channelSize), 16 | register: make(chan *Client, channelSize), 17 | unregister: make(chan *Client, channelSize), 18 | quit: make(chan bool, 1), 19 | } 20 | 21 | go fo.run() 22 | 23 | return fo 24 | } 25 | 26 | // Broadcast sends a message to all registered clients. 27 | func (fo *Fanout) Broadcast(event WebsocketEvent) { 28 | fo.broadcast <- event 29 | } 30 | 31 | // RegisterClient registers a client to include in broadcasts. 32 | func (fo *Fanout) RegisterClient(c *Client) { 33 | fo.register <- c 34 | } 35 | 36 | // UnregisterClient removes it from the broadcast. 37 | func (fo *Fanout) UnregisterClient(c *Client) { 38 | fo.unregister <- c 39 | } 40 | 41 | // Quit stops broadcasting messages over the channel. 42 | func (fo *Fanout) Quit() { 43 | fo.quit <- true 44 | } 45 | 46 | // run is the main loop. It provides a mechanism to register/unregister clients 47 | // and will broadcast messages as they come in. 48 | func (fo *Fanout) run() { 49 | clients := map[*Client]bool{} 50 | 51 | for { 52 | select { 53 | case <-fo.quit: 54 | for client := range clients { 55 | client.Quit() 56 | } 57 | case c := <-fo.register: 58 | clients[c] = true 59 | case c := <-fo.unregister: 60 | if _, ok := clients[c]; ok { 61 | delete(clients, c) 62 | c.Quit() 63 | } 64 | case broadcast := <-fo.broadcast: 65 | for client := range clients { 66 | client.Send(broadcast) 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /server/loader/script.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "io/fs" 5 | 6 | "tidbyt.dev/pixlet/runtime" 7 | ) 8 | 9 | func loadScript(appID string, fs fs.FS) (*runtime.Applet, error) { 10 | return runtime.NewAppletFromFS(appID, fs) 11 | } 12 | -------------------------------------------------------------------------------- /src/Main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import Container from '@mui/material/Container'; 5 | import Box from '@mui/material/Box'; 6 | import Grid from '@mui/material/Grid'; 7 | 8 | import AppBar from './features/appbar/AppBar'; 9 | import ConfigManager from './features/config/ConfigManager'; 10 | import ErrorManager from './features/errors/ErrorManager'; 11 | import ErrorSnackbar from './features/errors/ErrorSnackbar'; 12 | import ParamSetter from './features/config/ParamSetter'; 13 | import Preview from './features/preview/Preview'; 14 | import Schema from './features/schema/Schema'; 15 | import WatcherManager from './features/watcher/WatcherManager'; 16 | import Controls from './features/controls/Controls'; 17 | import { Typography } from '@mui/material'; 18 | 19 | 20 | export default function Main() { 21 | const schema = useSelector(state => state.schema); 22 | 23 | let size = 12; 24 | if (schema.value.schema.length > 0) { 25 | size = 8; 26 | } 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ) 51 | } -------------------------------------------------------------------------------- /src/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/src/favicon.png -------------------------------------------------------------------------------- /src/features/appbar/AppBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import AppBar from '@mui/material/AppBar'; 4 | import Toolbar from '@mui/material/Toolbar'; 5 | 6 | import Logo from './logo.svg'; 7 | import styles from './styles.css'; 8 | import { solarized } from '../theme/colors'; 9 | 10 | 11 | export default function NavBar() { 12 | return ( 13 | 14 | 15 |
16 | 17 |
18 |
19 |
20 | ) 21 | } -------------------------------------------------------------------------------- /src/features/appbar/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/appbar/styles.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | height: 26px; 3 | } 4 | 5 | .title { 6 | flex-grow: 1; 7 | } -------------------------------------------------------------------------------- /src/features/config/ConfigManager.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import fetchPreview from '../preview/actions'; 6 | 7 | 8 | export default function ConfigManager() { 9 | const config = useSelector(state => state.config); 10 | const loading = useSelector(state => state.param.loading); 11 | const preview = useSelector(state => state.preview); 12 | const navigate = useNavigate(); 13 | 14 | const updatePreviews = (formData, params) => { 15 | navigate({ search: params.toString() }); 16 | fetchPreview(formData); 17 | } 18 | 19 | useEffect(() => { 20 | const formData = new FormData(); 21 | const params = new URLSearchParams(); 22 | 23 | Object.entries(config).forEach((entry) => { 24 | const [id, item] = entry; 25 | 26 | // Not all config values fit inside a query parameter, most notably 27 | // images. If they don't fit, simply leave them out of the query 28 | // string. The downside is a refresh will lose that state. 29 | if (item.value.length < 1024) { 30 | params.set(id, item.value) 31 | } 32 | 33 | formData.set(id, item.value); 34 | }); 35 | 36 | if (!loading || !('img' in preview)) { 37 | updatePreviews(formData, params); 38 | } 39 | }, [config]); 40 | 41 | return null; 42 | } -------------------------------------------------------------------------------- /src/features/config/ParamSetter.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { set } from './configSlice'; 4 | import { loading } from './paramSlice'; 5 | 6 | 7 | export default function ParamSetter() { 8 | const params = new URLSearchParams(document.location.search); 9 | const dispatch = useDispatch(); 10 | 11 | useEffect(() => { 12 | params.forEach((value, key) => { 13 | dispatch(set({ 14 | id: key, 15 | value: value, 16 | })); 17 | }); 18 | dispatch(loading(false)); 19 | }, []); 20 | 21 | return null; 22 | }; -------------------------------------------------------------------------------- /src/features/config/actions.js: -------------------------------------------------------------------------------- 1 | import { update, clear } from './configSlice'; 2 | import store from '../../store'; 3 | 4 | export function setConfig(data) { 5 | store.dispatch(update(data)); 6 | } 7 | 8 | export function resetConfig() { 9 | store.dispatch(clear()); 10 | } -------------------------------------------------------------------------------- /src/features/config/configSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | 4 | export const configSlice = createSlice({ 5 | name: 'config', 6 | initialState: { 7 | }, 8 | reducers: { 9 | set: (state = initialState, action) => { 10 | let config = state; 11 | config[action.payload.id] = action.payload 12 | return state; 13 | }, 14 | update: (state = initialState, action) => { 15 | state = action.payload; 16 | return state; 17 | }, 18 | clear: (state = initialState, action) => { 19 | state = {}; 20 | return state; 21 | }, 22 | remove: (state = initialState, action) => { 23 | let config = state; 24 | if (action.payload in config) { 25 | delete config[action.payload]; 26 | } 27 | return state; 28 | }, 29 | }, 30 | }); 31 | 32 | export const { set, remove, update, clear } = configSlice.actions; 33 | export default configSlice.reducer; -------------------------------------------------------------------------------- /src/features/config/paramSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | 4 | export const paramSlice = createSlice({ 5 | name: 'param', 6 | initialState: { 7 | loading: true, 8 | }, 9 | reducers: { 10 | loading: (state = initialState, action) => { 11 | return { loading: action.payload }; 12 | }, 13 | }, 14 | }); 15 | 16 | export const { loading } = paramSlice.actions; 17 | export default paramSlice.reducer; -------------------------------------------------------------------------------- /src/features/errors/ErrorManager.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { useSnackbar } from 'notistack'; 4 | 5 | 6 | export default function ErrorManager() { 7 | const { enqueueSnackbar, closeSnackbar } = useSnackbar(); 8 | const errors = useSelector(state => state.errors); 9 | 10 | useEffect(() => { 11 | for (const id in errors.active) { 12 | enqueueSnackbar(errors.active[id].message, { key: id, variant: 'error', persist: true }); 13 | } 14 | for (const id in errors.inactive) { 15 | closeSnackbar(id); 16 | } 17 | }, [errors]); 18 | 19 | return null; 20 | } -------------------------------------------------------------------------------- /src/features/errors/ErrorSnackbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SnackbarProvider } from 'notistack'; 3 | import styles from './styles.css'; 4 | 5 | export default function ErrorSnackbar(props) { 6 | const notistackRef = React.createRef(); 7 | 8 | return ( 9 | 22 | {props.children} 23 | 24 | ) 25 | }; -------------------------------------------------------------------------------- /src/features/errors/errorSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | export const errorSlice = createSlice({ 4 | name: 'errors', 5 | initialState: { 6 | active: {}, 7 | inactive: {}, 8 | }, 9 | reducers: { 10 | set: (state = initialState, action) => { 11 | // TODO: Fix this in pixlet. 12 | if (action.payload.message.includes("didn't export a main() function")) { 13 | return state; 14 | } 15 | 16 | // If the error already exists, make no changes. 17 | if (action.payload.id in state.active) { 18 | return state; 19 | } 20 | 21 | let active = {}; 22 | active[action.payload.id] = action.payload; 23 | 24 | return { 25 | active: active, 26 | inactive: state.active, 27 | } 28 | }, 29 | clear: (state = initialState) => { 30 | return { 31 | active: {}, 32 | inactive: state.active, 33 | } 34 | }, 35 | }, 36 | }); 37 | 38 | export const { set, remove, clear } = errorSlice.actions; 39 | export default errorSlice.reducer; -------------------------------------------------------------------------------- /src/features/errors/styles.css: -------------------------------------------------------------------------------- 1 | .error { 2 | background-color: #dc322f !important; 3 | color: #eee8d5 !important; 4 | } -------------------------------------------------------------------------------- /src/features/handlers/handlerSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | export const handlerSlice = createSlice({ 4 | name: 'handler', 5 | initialState: { 6 | loading: false, 7 | values: {} 8 | }, 9 | reducers: { 10 | update: (state = initialState, action) => { 11 | let up = state; 12 | up.values[action.payload.id] = action.payload.value 13 | return up; 14 | }, 15 | loading: (state = initialState, action) => { 16 | return { ...state, loading: action.payload } 17 | }, 18 | }, 19 | }); 20 | 21 | export const { update, loading } = handlerSlice.actions; 22 | export default handlerSlice.reducer; -------------------------------------------------------------------------------- /src/features/preview/actions.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { update, loading } from './previewSlice'; 3 | import { set as setError, clear as clearErrors } from '../errors/errorSlice'; 4 | import store from '../../store'; 5 | import axiosRetry from 'axios-retry'; 6 | 7 | let timeout = null; 8 | 9 | export default function fetchPreview(formData) { 10 | const client = axios.create(); 11 | axiosRetry(client, { 12 | retries: 5, 13 | retryDelay: () => 1000, 14 | retryCondition: (err) => { 15 | return err.response.status === 404; 16 | }, 17 | }); 18 | 19 | client.post(`${PIXLET_API_BASE}/api/v1/preview`, formData) 20 | .then(res => { 21 | document.title = res.data.title; 22 | store.dispatch(update(res.data)); 23 | if ('error' in res.data) { 24 | store.dispatch(setError({ id: res.data.error, message: res.data.error })); 25 | } else { 26 | store.dispatch(clearErrors()); 27 | } 28 | }) 29 | .catch(err => { 30 | if (err.response.status == 404) { 31 | store.dispatch(setError({ id: err, message: "error with pixlet, please refresh page" })); 32 | return; 33 | } 34 | store.dispatch(setError({ id: err, message: err })); 35 | }) 36 | .then(() => { 37 | store.dispatch(loading(false)); 38 | }) 39 | } -------------------------------------------------------------------------------- /src/features/preview/mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tidbyt/pixlet/c29059778792f1731822098aeb71f9bf5567b38b/src/features/preview/mask.png -------------------------------------------------------------------------------- /src/features/preview/previewSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | export const previewSlice = createSlice({ 4 | name: 'preview', 5 | initialState: { 6 | loading: false, 7 | value: { 8 | img: '', 9 | img_type: '', 10 | title: 'Pixlet', 11 | } 12 | }, 13 | reducers: { 14 | update: (state = initialState, action) => { 15 | let up = state; 16 | 17 | if ('img' in action.payload) { 18 | up.value.img = action.payload.img; 19 | } 20 | 21 | if ('img_type' in action.payload) { 22 | up.value.img_type = action.payload.img_type; 23 | } 24 | 25 | if ('title' in action.payload) { 26 | up.value.title = action.payload.title; 27 | } 28 | 29 | return up; 30 | }, 31 | loading: (state = initialState, action) => { 32 | return { ...state, loading: action.payload } 33 | }, 34 | }, 35 | }); 36 | 37 | export const { update, loading } = previewSlice.actions; 38 | export default previewSlice.reducer; -------------------------------------------------------------------------------- /src/features/preview/styles.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background-color: black; 3 | } 4 | 5 | .image { 6 | image-rendering: pixelated; 7 | image-rendering: -moz-crisp-edges; 8 | image-rendering: crisp-edges; 9 | width: 100%; 10 | mask-size: contain; 11 | -webkit-mask-size: contain; 12 | mask-image: url('./mask.png'); 13 | -webkit-mask-image: url('./mask.png'); 14 | } -------------------------------------------------------------------------------- /src/features/schema/Field.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Accordion from '@mui/material/Accordion'; 4 | import AccordionSummary from '@mui/material/AccordionSummary'; 5 | import AccordionDetails from '@mui/material/AccordionDetails'; 6 | import Typography from '@mui/material/Typography'; 7 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 8 | 9 | import FieldDetails from './FieldDetails'; 10 | import FieldIcon from './FieldIcon'; 11 | 12 | export default function Field(props) { 13 | const field = props.field; 14 | 15 | const [expanded, setExpanded] = React.useState(false); 16 | 17 | const handleChange = (panel) => (event, isExpanded) => { 18 | setExpanded(isExpanded ? panel : false); 19 | }; 20 | 21 | return ( 22 | 23 | } 25 | aria-controls="panel1bh-content" 26 | id="panel1bh-header" 27 | > 28 | 29 | 30 | 31 | 32 | {field.name} 33 | 34 | {field.description} 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } -------------------------------------------------------------------------------- /src/features/schema/FieldDetails.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import OAuth2 from './fields/oauth2/OAuth2'; 4 | import PhotoSelect from './fields/photoselect/PhotoSelect'; 5 | import RawPhotoSelect from './fields/photoselect/RawPhotoSelect'; 6 | import Toggle from './fields/Toggle'; 7 | import Color from './fields/Color'; 8 | import DateTime from './fields/DateTime'; 9 | import Dropdown from './fields/Dropdown'; 10 | import LocationBased from './fields/location/LocationBased'; 11 | import LocationForm from './fields/location/LocationForm'; 12 | import TextInput from './fields/TextInput'; 13 | import Typeahead from './fields/Typeahead'; 14 | import Typography from '@mui/material/Typography'; 15 | 16 | 17 | export default function FieldDetails({ field }) { 18 | switch (field.type) { 19 | case 'datetime': 20 | return 21 | case 'dropdown': 22 | return 23 | case 'location': 24 | return 25 | case 'locationbased': 26 | return 27 | case 'oauth2': 28 | return 29 | case 'png': 30 | return 31 | case 'text': 32 | return 33 | case 'onoff': 34 | return 35 | case 'typeahead': 36 | return 37 | case 'color': 38 | return 39 | default: 40 | return Unsupported type: {field.type} 41 | } 42 | } -------------------------------------------------------------------------------- /src/features/schema/FieldIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { findIconDefinition } from '@fortawesome/fontawesome-svg-core'; 4 | 5 | import Icon from '@mui/material/Icon'; 6 | 7 | import styles from './styles.css'; 8 | 9 | 10 | export default function FieldIcon(props) { 11 | const iconName = props.icon; 12 | 13 | if (!iconName) { 14 | return null; 15 | } 16 | 17 | const faIconName = iconName.replace(/[A-Z]/g, m => "-" + m.toLowerCase()); 18 | 19 | let icoDef = findIconDefinition({ prefix: 'fas', iconName: faIconName }); 20 | if (icoDef) { 21 | return ; 22 | } 23 | 24 | icoDef = findIconDefinition({ prefix: 'fab', iconName: faIconName }); 25 | if (icoDef) { 26 | return ; 27 | } 28 | 29 | return ( 30 | {iconName} 31 | ) 32 | } -------------------------------------------------------------------------------- /src/features/schema/Schema.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import refreshSchema from './actions'; 5 | import Field from './Field'; 6 | import Generated from './fields/Generated'; 7 | 8 | 9 | export default function Schema() { 10 | const schema = useSelector(state => state.schema); 11 | 12 | useEffect(() => { 13 | refreshSchema(); 14 | }, []); 15 | 16 | return ( 17 |
18 | { 19 | schema.value.schema.map((field) => { 20 | if (field.type === "generated") { 21 | return 22 | } 23 | 24 | return 25 | }) 26 | } 27 | { 28 | schema.generated.schema.map((field) => { 29 | // A generated field cannot return a generated field, that 30 | // would be chaos! 31 | return 32 | }) 33 | } 34 |
35 | ); 36 | } -------------------------------------------------------------------------------- /src/features/schema/actions.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import axiosRetry from 'axios-retry'; 3 | 4 | import { update, loading, error } from './schemaSlice'; 5 | import store from "../../store"; 6 | 7 | 8 | export default function refreshSchema() { 9 | store.dispatch(loading(true)); 10 | 11 | const client = axios.create(); 12 | axiosRetry(client, { 13 | retries: 5, 14 | retryDelay: () => 1000, 15 | retryCondition: (err) => { 16 | return err.response.status === 404; 17 | }, 18 | }); 19 | 20 | client.get(`${PIXLET_API_BASE}/api/v1/schema`) 21 | .then(res => { 22 | store.dispatch(update(res.data)); 23 | }) 24 | .catch(err => { 25 | if (err.response.status == 404) { 26 | store.dispatch(error({ id: err, message: "error with pixlet, please refresh page" })); 27 | return; 28 | } 29 | store.dispatch(error(err)); 30 | }) 31 | .then(() => { 32 | store.dispatch(loading(false)); 33 | }); 34 | } -------------------------------------------------------------------------------- /src/features/schema/fields/Color.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import { useSelector, useDispatch } from 'react-redux'; 4 | import { ColorPicker, createColor } from "material-ui-color"; 5 | import { set } from '../../config/configSlice'; 6 | 7 | 8 | export default function Color({ field }) { 9 | const [color, setColor] = useState(createColor(field.default)); 10 | const [palette, setPalette] = useState(field.palette); 11 | const config = useSelector(state => state.config); 12 | const dispatch = useDispatch(); 13 | 14 | // TODO: figure out how to update the palette when schema changes without 15 | // a refresh. 16 | useEffect(() => { 17 | if (field.id in config) { 18 | setColor(createColor(config[field.id].value)); 19 | } else if (field.default) { 20 | dispatch(set({ 21 | id: field.id, 22 | value: field.default, 23 | })); 24 | } 25 | }, [config]) 26 | 27 | const onChange = (value) => { 28 | setColor(value); 29 | 30 | // Skip updates that contain an error. 31 | if (value.hasOwnProperty("error")) { 32 | return; 33 | } 34 | 35 | dispatch(set({ 36 | id: field.id, 37 | value: "#" + value.hex, 38 | })); 39 | } 40 | 41 | return ( 42 | 43 | ) 44 | } -------------------------------------------------------------------------------- /src/features/schema/fields/DateTime.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | 4 | import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; 5 | import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; 6 | import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; 7 | import TextField from '@mui/material/TextField'; 8 | 9 | import { set, remove } from '../../config/configSlice' 10 | 11 | 12 | export default function DateTime({ field }) { 13 | const [dateTime, setDateTime] = useState(new Date()); 14 | const config = useSelector(state => state.config); 15 | const dispatch = useDispatch(); 16 | 17 | useEffect(() => { 18 | if (field.id in config) { 19 | setDateTime(new Date(config[field.id].value)); 20 | } 21 | }, [config]); 22 | 23 | const onChange = (timestamp) => { 24 | if (!timestamp) { 25 | setDateTime(new Date()); 26 | dispatch(remove(field.id)); 27 | return; 28 | } 29 | 30 | setDateTime(timestamp); 31 | dispatch(set({ 32 | id: field.id, 33 | value: timestamp.toISOString(), 34 | })); 35 | } 36 | 37 | return ( 38 | 39 | } 41 | label={field.name} 42 | value={dateTime} 43 | onChange={onChange} 44 | onError={console.log} 45 | /> 46 | 47 | ); 48 | } -------------------------------------------------------------------------------- /src/features/schema/fields/Dropdown.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | 4 | import InputLabel from '@mui/material/InputLabel'; 5 | import MenuItem from '@mui/material/MenuItem'; 6 | import FormControl from '@mui/material/FormControl'; 7 | import Select from '@mui/material/Select'; 8 | 9 | import { set } from '../../config/configSlice'; 10 | 11 | 12 | export default function Dropdown({ field }) { 13 | const [value, setValue] = useState(field.default); 14 | const config = useSelector(state => state.config); 15 | const dispatch = useDispatch(); 16 | 17 | useEffect(() => { 18 | if (field.id in config) { 19 | setValue(config[field.id].value); 20 | } else if (field.default) { 21 | dispatch(set({ 22 | id: field.id, 23 | value: field.default, 24 | })); 25 | } 26 | }, [config]) 27 | 28 | const onChange = (event) => { 29 | setValue(event.target.value); 30 | dispatch(set({ 31 | id: field.id, 32 | value: event.target.value, 33 | })); 34 | } 35 | 36 | return ( 37 | 38 | {field.name} 39 | 48 | 49 | ); 50 | } -------------------------------------------------------------------------------- /src/features/schema/fields/Generated.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | 4 | import { callGeneratedHandler } from '../../handlers/actions'; 5 | import { set as setError } from '../../errors/errorSlice'; 6 | 7 | 8 | export default function Generated({ field }) { 9 | const [source, setSource] = useState(null); 10 | const config = useSelector(state => state.config); 11 | const schema = useSelector(state => state.schema); 12 | const dispatch = useDispatch(); 13 | 14 | useEffect(() => { 15 | onChange(source); 16 | }, [config]) 17 | 18 | useEffect(() => { 19 | setSource(getSourceField()); 20 | }, [schema]) 21 | 22 | const onChange = (source_field) => { 23 | if (source_field && source_field.id in config) { 24 | callGeneratedHandler(field.id, field.handler, config[source_field.id].value); 25 | } 26 | } 27 | 28 | const getSourceField = () => { 29 | if (schema.value.schema.length == 0) { 30 | return null; 31 | } 32 | 33 | for (let i = 0; i < schema.value.schema.length; i++) { 34 | if (schema.value.schema[i].id === field.source) { 35 | return schema.value.schema[i]; 36 | } 37 | } 38 | 39 | let msg = `schema.Generated references source that does not exist: ${field.source}`; 40 | dispatch(setError({ id: msg, message: msg })); 41 | return null; 42 | } 43 | 44 | return null; 45 | } -------------------------------------------------------------------------------- /src/features/schema/fields/Toggle.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import { useSelector, useDispatch } from 'react-redux'; 4 | import Switch from '@mui/material/Switch'; 5 | import { set } from '../../config/configSlice'; 6 | 7 | 8 | export default function Toggle({ field }) { 9 | const [toggle, setToggle] = useState(JSON.parse(field.default)); 10 | const config = useSelector(state => state.config); 11 | const dispatch = useDispatch(); 12 | 13 | useEffect(() => { 14 | if (field.id in config) { 15 | setToggle(JSON.parse(config[field.id].value)); 16 | } else if (JSON.parse(field.default)) { 17 | dispatch(set({ 18 | id: field.id, 19 | value: field.default, 20 | })); 21 | } 22 | }, [config]) 23 | 24 | const onChange = (event) => { 25 | setToggle(event.target.checked); 26 | dispatch(set({ 27 | id: field.id, 28 | value: JSON.stringify(event.target.checked), 29 | })) 30 | } 31 | 32 | return ( 33 | 34 | ) 35 | } -------------------------------------------------------------------------------- /src/features/schema/fields/Typeahead.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | 4 | import Autocomplete from '@mui/material/Autocomplete'; 5 | import TextField from '@mui/material/TextField'; 6 | 7 | import { set, remove } from '../../config/configSlice'; 8 | import { callHandler } from '../../handlers/actions'; 9 | 10 | 11 | export default function Typeahead({ field }) { 12 | const [value, setValue] = useState(null); 13 | const config = useSelector(state => state.config); 14 | const dispatch = useDispatch(); 15 | const handlerResults = useSelector(state => state.handlers) 16 | 17 | useEffect(() => { 18 | if (field.id in config) { 19 | setValue(JSON.parse(config[field.id].value)); 20 | } 21 | }, [config]) 22 | 23 | const onChange = (event, newValue) => { 24 | if (newValue) { 25 | setValue(newValue); 26 | dispatch(set({ 27 | id: field.id, 28 | value: JSON.stringify(newValue), 29 | })) 30 | } else { 31 | setValue(null); 32 | dispatch(remove(field.id)); 33 | } 34 | } 35 | 36 | let options = []; 37 | if (field.id in handlerResults.values) { 38 | options = handlerResults.values[field.id]; 39 | } 40 | 41 | return ( 42 | { 47 | callHandler(field.id, field.handler, v); 48 | }} 49 | onChange={onChange} 50 | options={options} 51 | getOptionLabel={(option) => option.display} 52 | renderInput={(params) => } 53 | /> 54 | ) 55 | } -------------------------------------------------------------------------------- /src/features/schema/fields/oauth2/OAuth2Handler.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useEffect } from 'react'; 4 | import CircularProgress from '@mui/material/CircularProgress'; 5 | import Grid from '@mui/material/Grid'; 6 | 7 | 8 | export default function OAuth2Handler() { 9 | const params = new URLSearchParams(window.location.search); 10 | 11 | useEffect(() => { 12 | window.addEventListener("message", function (event) { 13 | if (event.data.message === "requestResult") { 14 | event.source.postMessage({ "message": "deliverResult", result: { code: params.get("code") } }); 15 | } 16 | }); 17 | }, []); 18 | 19 | return ( 20 | 28 | 29 | 30 | 31 | 32 | ) 33 | } -------------------------------------------------------------------------------- /src/features/schema/fields/photoselect/cropImage.js: -------------------------------------------------------------------------------- 1 | export default function getCroppedImg(imageSrc, pixelCrop) { 2 | const image = new Image(); 3 | image.src = imageSrc; 4 | 5 | const canvas = document.createElement('canvas'); 6 | canvas.width = 64; 7 | canvas.height = 32; 8 | 9 | const ctx = canvas.getContext('2d'); 10 | ctx.drawImage( 11 | image, 12 | pixelCrop.x, 13 | pixelCrop.y, 14 | pixelCrop.width, 15 | pixelCrop.height, 16 | 0, 17 | 0, 18 | 64, 19 | 32 20 | ); 21 | 22 | return canvas.toDataURL('image/jpeg').replace('data:image/jpeg;base64,', ''); 23 | } 24 | -------------------------------------------------------------------------------- /src/features/schema/fields/photoselect/styles.css: -------------------------------------------------------------------------------- 1 | .imageCropper { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | } 8 | 9 | .cropContainer { 10 | position: absolute; 11 | top: 0; 12 | left: 0; 13 | right: 0; 14 | bottom: 80px; 15 | } 16 | 17 | .controls { 18 | position: absolute; 19 | bottom: 20px; 20 | left: 50%; 21 | width: 50%; 22 | transform: translateX(-50%); 23 | height: 40px; 24 | display: flex; 25 | align-items: center; 26 | } -------------------------------------------------------------------------------- /src/features/schema/schemaSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | export const schemaSlice = createSlice({ 4 | name: 'schema', 5 | initialState: { 6 | loading: false, 7 | error: '', 8 | value: { 9 | version: '1', 10 | schema: [] 11 | }, 12 | generated: { 13 | version: '1', 14 | schema: [] 15 | } 16 | }, 17 | reducers: { 18 | update: (state = initialState, action) => { 19 | return { ...state, value: action.payload, loading: false, error: '' } 20 | }, 21 | updateGenerated: (state = initialState, action) => { 22 | return { ...state, generated: action.payload, loading: false, error: '' } 23 | }, 24 | loading: (state = initialState, action) => { 25 | return { ...state, loading: action.payload } 26 | }, 27 | error: (state = initialState, action) => { 28 | return { ...state, error: action.payload } 29 | }, 30 | }, 31 | }); 32 | 33 | export const { update, updateGenerated, loading, error } = schemaSlice.actions; 34 | export default schemaSlice.reducer; -------------------------------------------------------------------------------- /src/features/schema/styles.css: -------------------------------------------------------------------------------- 1 | .materialIcons { 2 | font-family: 'Material Icons'; 3 | font-weight: normal; 4 | font-style: normal; 5 | font-size: 24px; /* Preferred icon size */ 6 | display: inline-block; 7 | line-height: 1; 8 | text-transform: none; 9 | letter-spacing: normal; 10 | word-wrap: normal; 11 | white-space: nowrap; 12 | direction: ltr; 13 | 14 | /* Support for all WebKit browsers. */ 15 | -webkit-font-smoothing: antialiased; 16 | /* Support for Safari and Chrome. */ 17 | text-rendering: optimizeLegibility; 18 | 19 | /* Support for Firefox. */ 20 | -moz-osx-font-smoothing: grayscale; 21 | 22 | /* Support for IE. */ 23 | font-feature-settings: 'liga'; 24 | } -------------------------------------------------------------------------------- /src/features/theme/DevToolsTheme.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { library } from '@fortawesome/fontawesome-svg-core'; 3 | import { fab } from '@fortawesome/free-brands-svg-icons'; 4 | import { fas } from '@fortawesome/free-solid-svg-icons'; 5 | 6 | import ThemeProvider from '@mui/system/ThemeProvider'; 7 | 8 | import { theme } from './theme'; 9 | import './styles.css'; 10 | 11 | 12 | export default function DevToolsTheme(props) { 13 | library.add(fas, fab); 14 | 15 | return ( 16 | 17 | {props.children} 18 | 19 | ); 20 | } -------------------------------------------------------------------------------- /src/features/theme/colors.js: -------------------------------------------------------------------------------- 1 | export const solarized = { 2 | base03: "#002b36", 3 | base02: "#073642", 4 | base01: "#586e75", 5 | base00: "#657b83", 6 | base0: "#839496", 7 | base1: "#93a1a1", 8 | base2: "#eee8d5", 9 | base3: "#fdf6e3", 10 | yellow: "#b58900", 11 | orange: "#cb4b16", 12 | red: "#dc322f", 13 | magenta: "#d33682", 14 | violet: "#6c71c4", 15 | blue: "#268bd2", 16 | cyan: "#2aa198", 17 | green: "#859900", 18 | }; -------------------------------------------------------------------------------- /src/features/theme/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | height: 100%; 4 | background-color: #002b36; 5 | } -------------------------------------------------------------------------------- /src/features/theme/theme.js: -------------------------------------------------------------------------------- 1 | 2 | import '@fontsource/barlow'; 3 | import '@fontsource/material-icons'; 4 | 5 | import { createTheme } from '@mui/material/styles'; 6 | 7 | import { solarized } from './colors'; 8 | 9 | 10 | export const theme = createTheme({ 11 | palette: { 12 | mode: 'light', 13 | primary: { 14 | main: solarized.cyan, 15 | }, 16 | secondary: { 17 | main: solarized.yellow, 18 | }, 19 | text: { 20 | primary: solarized.base1, 21 | secondary: solarized.base0, 22 | }, 23 | background: { 24 | paper: solarized.base02, 25 | default: solarized.base02, 26 | }, 27 | }, 28 | components: { 29 | MuiSvgIcon: { 30 | defaultProps: { 31 | htmlColor: solarized.base1, 32 | color: solarized.base1, 33 | }, 34 | }, 35 | }, 36 | typography: { 37 | fontFamily: [ 38 | 'Barlow', 39 | 'sans-serif', 40 | ].join(','), 41 | }, 42 | }); -------------------------------------------------------------------------------- /src/features/watcher/WatcherManager.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import Watcher from './watcher'; 4 | 5 | 6 | export default function WatcherManager() { 7 | useEffect(() => { 8 | new Watcher(); 9 | }, []); 10 | 11 | return null; 12 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Pixlet 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 5 | 6 | import Main from './Main'; 7 | import OAuth2Handler from './features/schema/fields/oauth2/OAuth2Handler'; 8 | import store from './store'; 9 | import DevToolsTheme from './features/theme/DevToolsTheme'; 10 | 11 | const App = () => { 12 | return ( 13 | 14 | 15 | 16 | 17 | } /> 18 | } /> 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | ReactDOM.render(, document.getElementById('app')); -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | 3 | import configSlice from './features/config/configSlice'; 4 | import errorSlice from './features/errors/errorSlice'; 5 | import handlerSlice from './features/handlers/handlerSlice'; 6 | import paramSlice from './features/config/paramSlice'; 7 | import previewSlice from './features/preview/previewSlice'; 8 | import schemaSlice from './features/schema/schemaSlice'; 9 | 10 | export default configureStore({ 11 | reducer: { 12 | config: configSlice, 13 | errors: errorSlice, 14 | handlers: handlerSlice, 15 | param: paramSlice, 16 | preview: previewSlice, 17 | schema: schemaSlice, 18 | }, 19 | }); -------------------------------------------------------------------------------- /starlarkutil/context.go: -------------------------------------------------------------------------------- 1 | package starlarkutil 2 | 3 | import ( 4 | "context" 5 | 6 | "go.starlark.net/starlark" 7 | ) 8 | 9 | const ( 10 | // ThreadContextKey is the name of the Starlark thread-local that we use to 11 | // pass context around. 12 | ThreadContextKey = "tidbyt.dev/pixlet/starlarkutil/$ctx" 13 | ) 14 | 15 | // AttachThreadContext attaches context to a Starlark thread so that it can be 16 | // retrieved latter with `ThreadContext`. 17 | func AttachThreadContext(ctx context.Context, thread *starlark.Thread) { 18 | thread.SetLocal(ThreadContextKey, ctx) 19 | } 20 | 21 | // ThreadContext returns the context that was attached to a Starlark thread 22 | // by `AttachThreadContext`. If no context is attached to the thread, it 23 | // returns a new, empty context. 24 | func ThreadContext(thread *starlark.Thread) context.Context { 25 | ctx, ok := thread.Local(ThreadContextKey).(context.Context) 26 | if !ok { 27 | ctx = context.Background() 28 | } 29 | return ctx 30 | } 31 | -------------------------------------------------------------------------------- /starlarkutil/context_test.go: -------------------------------------------------------------------------------- 1 | package starlarkutil 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "go.starlark.net/starlark" 9 | ) 10 | 11 | type contextKey string 12 | 13 | func TestThreadContext(t *testing.T) { 14 | var key contextKey 15 | key = "foo" 16 | val := "bar" 17 | 18 | ctx := context.WithValue( 19 | context.Background(), 20 | key, val, 21 | ) 22 | 23 | thread := &starlark.Thread{} 24 | AttachThreadContext(ctx, thread) 25 | 26 | ctxFromThread := ThreadContext(thread) 27 | assert.Same(t, ctx, ctxFromThread) 28 | assert.Equal(t, val, ctx.Value(key)) 29 | } 30 | 31 | func TestThreadWithoutContext(t *testing.T) { 32 | thread := &starlark.Thread{} 33 | assert.NotNil(t, ThreadContext(thread)) 34 | } 35 | -------------------------------------------------------------------------------- /starlarkutil/onexit.go: -------------------------------------------------------------------------------- 1 | package starlarkutil 2 | 3 | import "go.starlark.net/starlark" 4 | 5 | const ( 6 | // ThreadOnExitKey is the key used to store functions that should be called 7 | // when a thread exits. 8 | ThreadOnExitKey = "tidbyt.dev/pixlet/runtime/on_exit" 9 | ) 10 | 11 | type threadOnExitFunc func() 12 | 13 | func AddOnExit(thread *starlark.Thread, fn threadOnExitFunc) { 14 | if onExit, ok := thread.Local(ThreadOnExitKey).(*[]threadOnExitFunc); ok { 15 | *onExit = append(*onExit, fn) 16 | } else { 17 | thread.SetLocal(ThreadOnExitKey, &[]threadOnExitFunc{fn}) 18 | } 19 | } 20 | 21 | func RunOnExitFuncs(thread *starlark.Thread) { 22 | if onExit, ok := thread.Local(ThreadOnExitKey).(*[]threadOnExitFunc); ok { 23 | for _, fn := range *onExit { 24 | fn() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tools/generator/templates/source.star.tmpl: -------------------------------------------------------------------------------- 1 | """ 2 | Applet: {{.Name}} 3 | Summary: {{.Summary}} 4 | Description: {{.Desc}} 5 | Author: {{.Author}} 6 | """ 7 | 8 | load("render.star", "render") 9 | load("schema.star", "schema") 10 | 11 | DEFAULT_WHO = "world" 12 | 13 | def main(config): 14 | who = config.str("who", DEFAULT_WHO) 15 | message = "Hello, {}!".format(who) 16 | return render.Root( 17 | child = render.Text(message), 18 | ) 19 | 20 | def get_schema(): 21 | return schema.Schema( 22 | version = "1", 23 | fields = [ 24 | schema.Text( 25 | id = "who", 26 | name = "Who?", 27 | desc = "Who to say hello to.", 28 | icon = "user", 29 | ), 30 | ], 31 | ) 32 | -------------------------------------------------------------------------------- /tools/repo/repo.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gitsight/go-vcsurl" 7 | "github.com/go-git/go-git/v5" 8 | ) 9 | 10 | // IsInRepo determines if the provided directory is in the provided git 11 | // repository. Git repositories can be named differently on a local clone then 12 | // the remote. In addition, a git repo can have multiple remotes. In practice 13 | // though, the business logic question is something like: 14 | // "Am I in the community repo?". To answer that, this function iterates over 15 | // the remotes and if any of them have the same name as the one requested, it 16 | // returns true. Any other case returns false. 17 | func IsInRepo(dir string, name string) bool { 18 | repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{ 19 | DetectDotGit: true, 20 | }) 21 | if err != nil { 22 | return false 23 | } 24 | 25 | remotes, err := repo.Remotes() 26 | if err != nil { 27 | return false 28 | } 29 | 30 | for _, remote := range remotes { 31 | for _, url := range remote.Config().URLs { 32 | info, err := vcsurl.Parse(url) 33 | if err != nil { 34 | return false 35 | } 36 | 37 | if info.Name == name { 38 | return true 39 | } 40 | } 41 | } 42 | 43 | return false 44 | } 45 | 46 | func RepoRoot(dir string) (string, error) { 47 | repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{ 48 | DetectDotGit: true, 49 | }) 50 | if err != nil { 51 | return "", fmt.Errorf("couldn't instantiate repo: %w", err) 52 | } 53 | 54 | worktree, err := repo.Worktree() 55 | if err != nil { 56 | return "", fmt.Errorf("couldn't get worktree: %w", err) 57 | } 58 | 59 | return worktree.Filesystem.Root(), nil 60 | } 61 | -------------------------------------------------------------------------------- /tools/repo/repo_test.go: -------------------------------------------------------------------------------- 1 | package repo_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "tidbyt.dev/pixlet/tools/repo" 9 | ) 10 | 11 | func TestIsInRepo(t *testing.T) { 12 | root, err := os.Getwd() 13 | assert.NoError(t, err) 14 | 15 | tests := map[string]struct { 16 | repo string 17 | want bool 18 | }{ 19 | "Pixlet repo should always be true": { 20 | repo: "pixlet", 21 | want: true, 22 | }, 23 | "Any other repo should always be false": { 24 | repo: "foo", 25 | want: false, 26 | }, 27 | } 28 | 29 | for name, tc := range tests { 30 | t.Run(name, func(t *testing.T) { 31 | got := repo.IsInRepo(root, tc.repo) 32 | assert.Equal(t, tc.want, got) 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tools/singlefilefs.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type SingleFileFS struct { 10 | Path string 11 | baseFS fs.FS 12 | } 13 | 14 | func NewSingleFileFS(filePath string) *SingleFileFS { 15 | return &SingleFileFS{ 16 | Path: filePath, 17 | baseFS: os.DirFS(filepath.Dir(filePath)), 18 | } 19 | } 20 | 21 | func (sfs *SingleFileFS) Open(name string) (fs.File, error) { 22 | if name != "." && name != filepath.Base(sfs.Path) { 23 | return nil, fs.ErrNotExist 24 | } 25 | 26 | return sfs.baseFS.Open(name) 27 | } 28 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | resolve: { 3 | extensions: ['*', '.js', '.jsx'], 4 | }, 5 | experiments: { 6 | asyncWebAssembly: true, 7 | syncWebAssembly: true 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.(js|jsx)$/, 13 | exclude: /node_modules/, 14 | use: { 15 | loader: 'babel-loader' 16 | } 17 | }, 18 | { 19 | test: /\.css$/, 20 | use: [ 21 | 'style-loader', 22 | { 23 | loader: 'css-loader', 24 | options: { 25 | modules: true, 26 | }, 27 | }, 28 | ], 29 | }, 30 | { 31 | test: /\.(webp|jpe?g|gif|star)$/i, 32 | use: [ 33 | { 34 | loader: 'file-loader', 35 | }, 36 | ], 37 | }, 38 | { 39 | test: /\.svg$/, 40 | use: ['@svgr/webpack'], 41 | }, 42 | ] 43 | }, 44 | }; -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const webpack = require('webpack'); 4 | 5 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 6 | const HtmlWebPackPlugin = require("html-webpack-plugin"); 7 | 8 | const htmlPlugin = new HtmlWebPackPlugin({ 9 | template: './src/index.html', 10 | filename: './index.html', 11 | favicon: 'src/favicon.png' 12 | }); 13 | 14 | const copyPlugin = new CopyWebpackPlugin({ 15 | patterns: [ 16 | { from: "public", to: "../" }, 17 | ], 18 | }); 19 | 20 | let plugins = [htmlPlugin, copyPlugin]; 21 | plugins.push( 22 | new webpack.DefinePlugin({ 23 | 'PIXLET_API_BASE': JSON.stringify(''), 24 | }) 25 | ); 26 | 27 | module.exports = merge(common, { 28 | mode: 'development', 29 | devtool: 'source-map', 30 | devServer: { 31 | port: 3000, 32 | historyApiFallback: true, 33 | proxy: [ 34 | { 35 | context: ['/api'], 36 | target: 'http://localhost:8080', 37 | ws: true, 38 | }, 39 | ], 40 | }, 41 | plugins: plugins, 42 | }); -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const webpack = require('webpack'); 4 | const path = require('path'); 5 | const HtmlWebPackPlugin = require("html-webpack-plugin"); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | const htmlPlugin = new HtmlWebPackPlugin({ 9 | template: './src/index.html', 10 | filename: '../index.html', 11 | favicon: 'src/favicon.png' 12 | }); 13 | 14 | const copyPlugin = new CopyWebpackPlugin({ 15 | patterns: [ 16 | { from: "public", to: "../" }, 17 | ], 18 | }); 19 | 20 | 21 | let plugins = [htmlPlugin, copyPlugin]; 22 | plugins.push( 23 | new webpack.DefinePlugin({ 24 | 'PIXLET_API_BASE': JSON.stringify(''), 25 | }) 26 | ); 27 | 28 | module.exports = merge(common, { 29 | mode: 'production', 30 | devtool: 'source-map', 31 | output: { 32 | asyncChunks: true, 33 | publicPath: '/static/', 34 | path: path.resolve(__dirname, 'dist/static'), 35 | filename: '[name].[chunkhash].js', 36 | clean: true, 37 | }, 38 | plugins: plugins, 39 | }); --------------------------------------------------------------------------------