This is an automated email from the git hooks/post-receive script.
git pushed a commit to branch main
in repository ego.
View the commit online.
commit 0dad546d0c3371ba06542bb8b455c12ec796c5e4
Author: [email protected] <[email protected]>
AuthorDate: Wed Apr 1 17:19:34 2026 -0600
feat: add ego-docgen widget screenshot generator
New cmd/ego-docgen tool that discovers widget styles from the EFL
theme EDJ file and renders each as a PNG screenshot using headless
Edje rendering. Supports -list, -widget, -output, -theme flags.
Phase 1 of the widget documentation project.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---
cmd/ego-docgen/main.go | 296 ++++++++++++++++++++++++++++++++++++++++++++++
cmd/ego-docgen/styles.go | 180 ++++++++++++++++++++++++++++
cmd/ego-docgen/widgets.go | 199 +++++++++++++++++++++++++++++++
3 files changed, 675 insertions(+)
diff --git a/cmd/ego-docgen/main.go b/cmd/ego-docgen/main.go
new file mode 100644
index 0000000..c45cff3
--- /dev/null
+++ b/cmd/ego-docgen/main.go
@@ -0,0 +1,296 @@
+// Command ego-docgen renders widget screenshots from an EFL theme EDJ file.
+//
+// For each widget×style pair discovered in the theme, it creates a headless
+// Ecore_Evas buffer window, loads the corresponding Edje group, and saves the
+// result as a PNG file under the output directory tree:
+//
+// <output>/<widget>/<style>.png
+//
+// Usage:
+//
+// ego-docgen [-theme PATH] [-output DIR] [-widget NAME] [-list]
+package main
+
+/*
+#cgo pkg-config: efl-ui ecore-evas evas edje eina
+
+#include <Elementary.h>
+#include <Ecore_Evas.h>
+#include <Evas.h>
+#include <Edje.h>
+#include <stdlib.h>
+#include <string.h>
+
+// ego_part_text_set tries to set a text part on an Edje object. It first
+// tries partName directly; returns 1 on success and 0 when neither that part
+// exists in the group.
+static int ego_part_text_set(Evas_Object *obj, const char *part, const char *text) {
+ if (!edje_object_part_exists(obj, part)) return 0;
+ return edje_object_part_text_set(obj, part, text) ? 1 : 0;
+}
+*/
+import "C"
+
+import (
+ "flag"
+ "fmt"
+ "image"
+ "image/png"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "unsafe"
+)
+
+const defaultThemePath = "/usr/local/share/elementary/themes/default.edj"
+const defaultOutputDir = "docs/widgets"
+
+func main() {
+ themePath := flag.String("theme", defaultThemePath, "path to EFL theme EDJ file")
+ outputDir := flag.String("output", defaultOutputDir, "output root directory for PNG screenshots")
+ widgetFilter := flag.String("widget", "", "only render screenshots for this widget name")
+ listOnly := flag.Bool("list", false, "list discovered widgets and styles, then exit")
+ flag.Parse()
+
+ // Discover widget×style pairs from the theme file. This happens before
+ // elm_init because edje_file_collection_list only needs the Eina/Edje
+ // subsystems, not the full Elementary stack.
+ initEina()
+ styles, err := ListThemeStyles(*themePath)
+ if err != nil {
+ log.Fatalf("ego-docgen: listing theme styles: %v", err)
+ }
+ shutdownEina()
+
+ if *listOnly {
+ for _, ws := range styles {
+ if *widgetFilter != "" && ws.Widget != *widgetFilter {
+ continue
+ }
+ fmt.Printf("%-30s %s\n", ws.Widget, ws.Style)
+ }
+ return
+ }
+
+ // Filter by widget name when -widget is given.
+ var targets []WidgetStyle
+ for _, ws := range styles {
+ if *widgetFilter != "" && ws.Widget != *widgetFilter {
+ continue
+ }
+ targets = append(targets, ws)
+ }
+
+ if len(targets) == 0 {
+ if *widgetFilter != "" {
+ log.Fatalf("ego-docgen: no groups found for widget %q in %s", *widgetFilter, *themePath)
+ }
+ log.Fatalf("ego-docgen: no efl/ widget groups found in %s", *themePath)
+ }
+
+ // Initialise the full EFL stack in buffer mode so we can render Edje
+ // objects without a display server.
+ os.Setenv("ELM_ENGINE", "buffer")
+ initElm()
+
+ var failed int
+ for _, ws := range targets {
+ cfg := configFor(ws.Widget)
+ img, err := renderWidget(ws, cfg, *themePath)
+ if err != nil {
+ log.Printf("ego-docgen: render %s/%s: %v", ws.Widget, ws.Style, err)
+ failed++
+ continue
+ }
+
+ outPath := filepath.Join(*outputDir, ws.Widget, sanitizeStyle(ws.Style)+".png")
+ if err := savePNG(outPath, img); err != nil {
+ log.Printf("ego-docgen: save %s: %v", outPath, err)
+ failed++
+ continue
+ }
+ fmt.Printf("wrote %s\n", outPath)
+ }
+
+ shutdownElm()
+
+ if failed > 0 {
+ os.Exit(1)
+ }
+}
+
+// initEina initialises just the Eina and Edje subsystems used by
+// ListThemeStyles. This is separate from the full elm_init so we can query
+// the EDJ file without spinning up Elementary.
+func initEina() {
+ C.eina_init()
+ C.edje_init()
+}
+
+// shutdownEina tears down the Eina/Edje subsystems after ListThemeStyles.
+func shutdownEina() {
+ C.edje_shutdown()
+ C.eina_shutdown()
+}
+
+// initElm initialises the full Elementary stack (which includes Ecore, Evas,
+// Ecore_Evas, and Edje internally). Must be called after shutdownEina to
+// avoid double-initialising Edje.
+func initElm() {
+ progName := C.CString("ego-docgen")
+ defer C.free(unsafe.Pointer(progName))
+ argv := []*C.char{progName, nil}
+ argc := C.int(1)
+ C.elm_init(argc, &argv[0])
+}
+
+// shutdownElm tears down the Elementary stack.
+func shutdownElm() {
+ C.elm_shutdown()
+}
+
+// renderWidget creates a headless buffer window for ws using the dimensions
+// from cfg, loads the Edje group, optionally sets text and drag state, renders
+// one frame, and returns the pixel data as an *image.NRGBA.
+func renderWidget(ws WidgetStyle, cfg WidgetConfig, themePath string) (*image.NRGBA, error) {
+ w, h := cfg.Width, cfg.Height
+
+ ee := C.ecore_evas_buffer_new(C.int(w), C.int(h))
+ if ee == nil {
+ return nil, fmt.Errorf("ecore_evas_buffer_new(%d, %d) returned nil", w, h)
+ }
+ defer C.ecore_evas_free(ee)
+
+ C.ecore_evas_show(ee)
+ evas := C.ecore_evas_get(ee)
+
+ // Mark the full canvas as damaged so Evas redraws every pixel on the
+ // first render pass, giving a deterministic background.
+ C.evas_damage_rectangle_add(evas, 0, 0, C.int(w), C.int(h))
+
+ // White background rectangle so widgets appear on a clean surface.
+ bg := C.evas_object_rectangle_add(evas)
+ C.evas_object_color_set(bg, 255, 255, 255, 255)
+ C.evas_object_move(bg, 0, 0)
+ C.evas_object_resize(bg, C.int(w), C.int(h))
+ C.evas_object_show(bg)
+
+ // Create the Edje object and load the theme group.
+ obj := C.edje_object_add(evas)
+ if obj == nil {
+ return nil, fmt.Errorf("edje_object_add returned nil")
+ }
+ defer C.evas_object_del(obj)
+
+ cTheme := C.CString(themePath)
+ defer C.free(unsafe.Pointer(cTheme))
+ cGroup := C.CString(ws.Group)
+ defer C.free(unsafe.Pointer(cGroup))
+
+ if C.edje_object_file_set(obj, cTheme, cGroup) == 0 {
+ return nil, fmt.Errorf("edje_object_file_set failed for group %q", ws.Group)
+ }
+
+ // Set label text on the first matching text part.
+ if cfg.Text != "" {
+ cText := C.CString(cfg.Text)
+ defer C.free(unsafe.Pointer(cText))
+ // Try the EFL-style part first, then the legacy elm-style part.
+ for _, partName := range []string{"efl.text", "elm.text", "efl.text.main", "elm.text.main"} {
+ cPart := C.CString(partName)
+ if C.ego_part_text_set(obj, cPart, cText) != 0 {
+ C.free(unsafe.Pointer(cPart))
+ break
+ }
+ C.free(unsafe.Pointer(cPart))
+ }
+ }
+
+ // For range widgets (slider, progressbar) try to set a drag position so
+ // the screenshot shows a non-zero fill level.
+ if cfg.Progress > 0 {
+ dragVal := C.double(cfg.Progress)
+ for _, dragPart := range []string{"efl.drag", "elm.drag", "efl.slider", "elm.slider", "efl.indicator"} {
+ cDrag := C.CString(dragPart)
+ if C.edje_object_part_exists(obj, cDrag) != 0 {
+ C.edje_object_part_drag_value_set(obj, cDrag, dragVal, dragVal)
+ C.free(unsafe.Pointer(cDrag))
+ break
+ }
+ C.free(unsafe.Pointer(cDrag))
+ }
+ }
+
+ C.evas_object_move(obj, 0, 0)
+ C.evas_object_resize(obj, C.int(w), C.int(h))
+ C.evas_object_show(obj)
+
+ C.ecore_evas_manual_render(ee)
+
+ return readPixels(ee, w, h), nil
+}
+
+// readPixels reads back the ARGB32 premultiplied pixel buffer from ee,
+// un-premultiplies the alpha channel, and returns an *image.NRGBA of size w×h.
+// A nil pixels pointer returns an empty image rather than panicking.
+func readPixels(ee *C.Ecore_Evas, w, h int) *image.NRGBA {
+ pixels := C.ecore_evas_buffer_pixels_get(ee)
+ img := image.NewNRGBA(image.Rect(0, 0, w, h))
+ if pixels == nil {
+ return img
+ }
+
+ n := w * h
+ src := unsafe.Slice((*uint32)(pixels), n)
+
+ for i, px := range src {
+ // EFL pixel layout: bits 31-24 = A, 23-16 = R, 15-8 = G, 7-0 = B,
+ // with premultiplied alpha.
+ a := uint8(px >> 24)
+ r := uint8(px >> 16)
+ g := uint8(px >> 8)
+ b := uint8(px)
+
+ // Un-premultiply: scale RGB by 255/A using integer arithmetic with
+ // rounding. Fully transparent (a==0) and fully opaque (a==255)
+ // pixels require no scaling.
+ if a > 0 && a < 255 {
+ fa := uint32(a)
+ r = uint8((uint32(r)*255 + fa/2) / fa)
+ g = uint8((uint32(g)*255 + fa/2) / fa)
+ b = uint8((uint32(b)*255 + fa/2) / fa)
+ }
+
+ off := i * 4
+ img.Pix[off+0] = r
+ img.Pix[off+1] = g
+ img.Pix[off+2] = b
+ img.Pix[off+3] = a
+ }
+ return img
+}
+
+// savePNG writes img as a PNG file to path, creating any missing parent
+// directories with mode 0o755.
+func savePNG(path string, img image.Image) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return fmt.Errorf("creating directory: %w", err)
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ return fmt.Errorf("creating file: %w", err)
+ }
+ defer f.Close()
+ if err := png.Encode(f, img); err != nil {
+ return fmt.Errorf("encoding PNG: %w", err)
+ }
+ return nil
+}
+
+// sanitizeStyle converts a style string to a safe file-system name by
+// replacing "/" with "_" so that "horizontal/bar" becomes "horizontal_bar".
+// This avoids creating nested subdirectories under the widget directory.
+func sanitizeStyle(style string) string {
+ return strings.ReplaceAll(style, "/", "_")
+}
diff --git a/cmd/ego-docgen/styles.go b/cmd/ego-docgen/styles.go
new file mode 100644
index 0000000..a1f43ae
--- /dev/null
+++ b/cmd/ego-docgen/styles.go
@@ -0,0 +1,180 @@
+// Package main implements the ego-docgen tool, which renders EFL widget
+// screenshots by loading theme groups from an EDJ file into headless buffer
+// windows and saving each widget×style pair as a PNG.
+package main
+
+/*
+#cgo pkg-config: edje eina
+
+#include <Edje.h>
+#include <Eina.h>
+#include <stdlib.h>
+
+// ego_eina_list_count wraps the static-inline eina_list_count so cgo can
+// call it without the compiler inlining the accounting struct access itself.
+static unsigned int ego_eina_list_count(const Eina_List *list) {
+ return eina_list_count(list);
+}
+
+// ego_eina_list_nth wraps the non-inline eina_list_nth for symmetry and to
+// make the call site in Go explicit about the C boundary.
+static void *ego_eina_list_nth(const Eina_List *list, unsigned int n) {
+ return eina_list_nth(list, n);
+}
+
+// ego_eina_list_data_get wraps the static-inline eina_list_data_get.
+static void *ego_eina_list_data_get(const Eina_List *list) {
+ return eina_list_data_get(list);
+}
+
+// ego_eina_list_next wraps the static-inline eina_list_next.
+static Eina_List *ego_eina_list_next(const Eina_List *list) {
+ return eina_list_next(list);
+}
+*/
+import "C"
+
+import (
+ "strings"
+ "unsafe"
+)
+
+// WidgetStyle holds a parsed widget name and style extracted from an EDJ
+// group name.
+type WidgetStyle struct {
+ // Widget is the base widget name, e.g. "button" or "progressbar".
+ Widget string
+ // Style is the variant name. For the primary group it is "default"; for
+ // named variants it is the colon-suffix or sub-path, e.g. "anchor" or
+ // "horizontal/bar".
+ Style string
+ // Group is the full original group name as it appears in the EDJ file.
+ Group string
+}
+
+// skipPrefixes lists the group-name prefixes that should not be rendered as
+// widget screenshots. They are internal theme parts (cursors, focus
+// highlights, pointer bitmaps, window borders, code-editor pieces, etc.).
+var skipPrefixes = []string{
+ "efl/cursor/",
+ "efl/focus_highlight/",
+ "efl/pointer",
+ "efl/border",
+ "efl/code/",
+ "efl/text/emoticon/",
+ "efl/text/handler/",
+ "efl/text/cursor",
+ "efl/text/selection",
+ "efl/text/anchor",
+ "efl/text/magnifier",
+ "efl/uiclock/",
+ "efl/win",
+ "efl/video",
+ "efl/collection",
+ "efl/tooltip",
+}
+
+// shouldSkipGroup returns true when group is an internal theme part that
+// should not appear in widget documentation screenshots.
+func shouldSkipGroup(group string) bool {
+ for _, prefix := range skipPrefixes {
+ if strings.HasPrefix(group, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+// parseGroup converts an EDJ group name into a WidgetStyle.
+//
+// Naming conventions found in the default EFL theme:
+//
+// efl/button → widget="button" style="default"
+// efl/button:anchor → widget="button" style="anchor"
+// efl/progressbar/horizontal → widget="progressbar" style="horizontal"
+// efl/progressbar/horizontal:bar → widget="progressbar" style="horizontal/bar"
+//
+// The function returns (ws, true) on success and (WidgetStyle{}, false) when
+// the group name is not an "efl/" widget group.
+func parseGroup(group string) (WidgetStyle, bool) {
+ const prefix = "efl/"
+ if !strings.HasPrefix(group, prefix) {
+ return WidgetStyle{}, false
+ }
+ if shouldSkipGroup(group) {
+ return WidgetStyle{}, false
+ }
+
+ rest := group[len(prefix):]
+
+ // Split on the first colon to separate the base path from the style suffix.
+ // e.g. "progressbar/horizontal:bar" → base="progressbar/horizontal", suffix="bar"
+ var base, styleSuffix string
+ if idx := strings.IndexByte(rest, ':'); idx >= 0 {
+ base = rest[:idx]
+ styleSuffix = rest[idx+1:]
+ } else {
+ base = rest
+ }
+
+ // The first path segment is the widget name.
+ parts := strings.SplitN(base, "/", 2)
+ widget := parts[0]
+
+ // Build the style string:
+ // - sub-path segments (after the widget) become the style prefix
+ // - colon suffix is appended with "/" separator when a sub-path exists
+ // - when neither sub-path nor colon suffix is present, style = "default"
+ var styleParts []string
+ if len(parts) == 2 && parts[1] != "" {
+ styleParts = append(styleParts, parts[1])
+ }
+ if styleSuffix != "" {
+ styleParts = append(styleParts, styleSuffix)
+ }
+
+ var style string
+ if len(styleParts) > 0 {
+ style = strings.Join(styleParts, "/")
+ } else {
+ style = "default"
+ }
+
+ return WidgetStyle{Widget: widget, Style: style, Group: group}, true
+}
+
+// ListThemeStyles reads the EDJ theme file at path and returns all widget×style
+// pairs derived from "efl/" groups, excluding internal theme parts.
+//
+// The function initialises and shuts down the Eina and Edje libraries
+// internally; it must not be called while Edje is already initialised (i.e.
+// it is called before elm_init in main).
+func ListThemeStyles(path string) ([]WidgetStyle, error) {
+ cpath := C.CString(path)
+ defer C.free(unsafe.Pointer(cpath))
+
+ lst := C.edje_file_collection_list(cpath)
+ if lst == nil {
+ // An empty or missing file returns a nil list; treat as empty rather
+ // than an error so -list still works gracefully.
+ return nil, nil
+ }
+ defer C.edje_file_collection_list_free(lst)
+
+ count := int(C.ego_eina_list_count(lst))
+ result := make([]WidgetStyle, 0, count)
+
+ node := lst
+ for node != nil {
+ data := C.ego_eina_list_data_get(node)
+ if data != nil {
+ groupName := C.GoString((*C.char)(data))
+ if ws, ok := parseGroup(groupName); ok {
+ result = append(result, ws)
+ }
+ }
+ node = C.ego_eina_list_next(node)
+ }
+
+ return result, nil
+}
diff --git a/cmd/ego-docgen/widgets.go b/cmd/ego-docgen/widgets.go
new file mode 100644
index 0000000..738e41b
--- /dev/null
+++ b/cmd/ego-docgen/widgets.go
@@ -0,0 +1,199 @@
+package main
+
+// WidgetConfig holds rendering hints for a specific widget type. The values
+// are used when creating the headless buffer window that the Edje object is
+// loaded into.
+type WidgetConfig struct {
+ // Text is optional label text set on the widget's text parts so that
+ // screenshots show representative content rather than blank placeholders.
+ Text string
+ // Width and Height are the pixel dimensions of the buffer window.
+ Width int
+ Height int
+ // Progress is used for range-type widgets (slider, progressbar) to set
+ // a representative fill level (0.0–1.0).
+ Progress float64
+}
+
+// defaultConfig is returned for any widget not explicitly listed in widgetConfigs.
+var defaultConfig = WidgetConfig{
+ Width: 200,
+ Height: 100,
+}
+
+// widgetConfigs maps a widget base name (as parsed from the EDJ group by
+// parseGroup) to its rendering configuration. Entries are only needed when
+// the default 200×100 size or empty text is not appropriate.
+var widgetConfigs = map[string]WidgetConfig{
+ "button": {
+ Text: "Button",
+ Width: 160,
+ Height: 48,
+ },
+ "check": {
+ Text: "Check",
+ Width: 160,
+ Height: 48,
+ },
+ "radio": {
+ Text: "Radio",
+ Width: 160,
+ Height: 48,
+ },
+ "slider": {
+ Width: 240,
+ Height: 60,
+ Progress: 0.4,
+ },
+ "slider_interval": {
+ Width: 240,
+ Height: 60,
+ Progress: 0.6,
+ },
+ "spin": {
+ Text: "42",
+ Width: 160,
+ Height: 48,
+ },
+ "spin_button": {
+ Text: "5",
+ Width: 180,
+ Height: 56,
+ },
+ "progressbar": {
+ Width: 240,
+ Height: 56,
+ Progress: 0.65,
+ },
+ "calendar": {
+ Width: 360,
+ Height: 320,
+ },
+ "frame": {
+ Text: "Frame",
+ Width: 220,
+ Height: 140,
+ },
+ "panes": {
+ Width: 300,
+ Height: 200,
+ },
+ "scroller": {
+ Width: 240,
+ Height: 180,
+ },
+ "separator": {
+ Width: 200,
+ Height: 24,
+ },
+ "text": {
+ Text: "Sample text",
+ Width: 240,
+ Height: 60,
+ },
+ "bg": {
+ Width: 240,
+ Height: 140,
+ },
+ "popup": {
+ Text: "Popup message",
+ Width: 280,
+ Height: 160,
+ },
+ "alert_popup": {
+ Text: "Alert!",
+ Width: 280,
+ Height: 160,
+ },
+ "tab_bar": {
+ Width: 320,
+ Height: 56,
+ },
+ "tab_page": {
+ Width: 320,
+ Height: 200,
+ },
+ "tab_pager": {
+ Width: 320,
+ Height: 240,
+ },
+ "datepicker": {
+ Width: 300,
+ Height: 200,
+ },
+ "timepicker": {
+ Width: 300,
+ Height: 200,
+ },
+ "tags": {
+ Text: "tag1",
+ Width: 240,
+ Height: 60,
+ },
+ "textpath": {
+ Text: "Path text",
+ Width: 240,
+ Height: 80,
+ },
+ "panel": {
+ Width: 260,
+ Height: 200,
+ },
+ "navigation_bar": {
+ Text: "Navigation",
+ Width: 320,
+ Height: 56,
+ },
+ "navigation_layout": {
+ Width: 320,
+ Height: 200,
+ },
+ "list": {
+ Width: 240,
+ Height: 200,
+ },
+ "list_item": {
+ Text: "List item",
+ Width: 240,
+ Height: 48,
+ },
+ "list_view": {
+ Width: 240,
+ Height: 200,
+ },
+ "grid": {
+ Width: 240,
+ Height: 200,
+ },
+ "grid_item": {
+ Text: "Grid item",
+ Width: 120,
+ Height: 120,
+ },
+ "group_item": {
+ Text: "Group",
+ Width: 240,
+ Height: 48,
+ },
+ "image_zoomable": {
+ Width: 240,
+ Height: 180,
+ },
+ "spotlight": {
+ Width: 320,
+ Height: 240,
+ },
+ "uiclock": {
+ Width: 200,
+ Height: 200,
+ },
+}
+
+// configFor returns the WidgetConfig for the given widget name, falling back
+// to defaultConfig when no explicit entry exists.
+func configFor(widget string) WidgetConfig {
+ if cfg, ok := widgetConfigs[widget]; ok {
+ return cfg
+ }
+ return defaultConfig
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.