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.

Reply via email to