Stan 2 weeks ago
commit
f47f6b3083

+ 7 - 0
component.go

@@ -0,0 +1,7 @@
+package ultraviolet
+
+type Component interface {
+	Build(Component)
+	Invalidate() bool
+	Clean()
+}

+ 21 - 0
component_button.go

@@ -0,0 +1,21 @@
+package ultraviolet
+
+type ButtonComponentModel struct {
+	Format string
+	Values []Value
+}
+
+type ButtonComponentController struct {
+	Handler ClickHandler
+}
+
+type ButtonComponentOptions struct {
+	View       View
+	Model      ButtonComponentModel
+	Controller ButtonComponentController
+}
+
+type ButtonComponent interface {
+	SetValue(format string, values []Value)
+	SetHandler(handler ClickHandler)
+}

+ 15 - 0
component_code.go

@@ -0,0 +1,15 @@
+package ultraviolet
+
+type CodeComponentModel struct {
+	Language string
+	Content  string
+}
+
+type CodeComponentOptions struct {
+	View  View
+	Model CodeComponentModel
+}
+
+type CodeComponent interface {
+	SetCode(language string, content string)
+}

+ 10 - 0
component_container.go

@@ -0,0 +1,10 @@
+//go:build js && wasm
+
+package ultraviolet
+
+type ContainerComponentOptions struct {
+	View View
+}
+
+type ContainerComponent interface {
+}

+ 14 - 0
component_frame.go

@@ -0,0 +1,14 @@
+package ultraviolet
+
+type FrameComponentModel struct {
+	URL string
+}
+
+type FrameComponentOptions struct {
+	View  View
+	Model FrameComponentModel
+}
+
+type FrameComponent interface {
+	SetUrl(url string)
+}

+ 10 - 0
component_handlers.go

@@ -0,0 +1,10 @@
+package ultraviolet
+
+const HANDLER_CLICK = "click"
+const HANDLER_EDIT = "edit"
+const HANDLER_CHANGE = "change"
+
+type AbstractHandler func(...any) error
+type AbstractHandlers map[string]AbstractHandler
+
+type ClickHandler func()

+ 14 - 0
component_image.go

@@ -0,0 +1,14 @@
+package ultraviolet
+
+type ImageComponentModel struct {
+	Path string
+}
+
+type ImageComponentOptions struct {
+	View  View
+	Model ImageComponentModel
+}
+
+type ImageComponent interface {
+	SetPath(path string)
+}

+ 14 - 0
component_markdown.go

@@ -0,0 +1,14 @@
+package ultraviolet
+
+type MarkdownComponentModel struct {
+	Content string
+}
+
+type MarkdownComponentOptions struct {
+	View  View
+	Model MarkdownComponentModel
+}
+
+type MarkdownComponent interface {
+	SetMarkdown(content string)
+}

+ 10 - 0
component_root.go

@@ -0,0 +1,10 @@
+//go:build js && wasm
+
+package ultraviolet
+
+type RootComponentOptions struct {
+	View View
+}
+
+type RootComponent interface {
+}

+ 90 - 0
component_web_button.go

@@ -0,0 +1,90 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"fmt"
+	"syscall/js"
+)
+
+type ButtonComponentWeb struct {
+	node    js.Value
+	view    View
+	format  string
+	values  []Value
+	handler ClickHandler
+}
+
+func NewButtonComponentWeb(view View) *ButtonComponentWeb {
+	return &ButtonComponentWeb{
+		view:   view,
+		values: []Value{},
+	}
+}
+
+// Component
+
+func (this *ButtonComponentWeb) Build(parent Component) {
+	values := []any{}
+	for _, text := range this.values {
+		if text.Value() == nil {
+			values = append(values, "")
+		} else {
+			values = append(values, text.Value().(string))
+		}
+	}
+
+	this.node = WebBuildDiv(
+		this.node,
+		parent,
+		this.view,
+		"",
+		fmt.Sprintf(
+			this.format,
+			values...,
+		),
+		AbstractHandlers{
+			HANDLER_CLICK: func(args ...any) error {
+				if this.handler == nil {
+					return nil
+				}
+
+				this.handler()
+				return nil
+			},
+		},
+	)
+}
+
+func (this *ButtonComponentWeb) Invalidate() bool {
+	for _, value := range this.values {
+		if !value.Invalidate() {
+			continue
+		}
+
+		return true
+	}
+
+	return false
+}
+
+func (this *ButtonComponentWeb) Clean() {
+	WebCleanChildren(this.node)
+}
+
+// JS
+
+func (this *ButtonComponentWeb) Node() js.Value {
+	return this.node
+}
+
+// ComponentButton
+
+func (this *ButtonComponentWeb) SetValue(format string, values []Value) {
+	this.format = format
+	this.values = values
+}
+
+func (this *ButtonComponentWeb) SetHandler(handler ClickHandler) {
+	this.handler = handler
+}

+ 58 - 0
component_web_code.go

@@ -0,0 +1,58 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"syscall/js"
+)
+
+type CodeComponentWeb struct {
+	node     js.Value
+	view     View
+	change   bool
+	language string
+	content  string
+}
+
+func NewCodeComponentWeb(view View) *CodeComponentWeb {
+	return &CodeComponentWeb{
+		view:   view,
+		change: true,
+	}
+}
+
+// Component
+
+func (this *CodeComponentWeb) Build(parent Component) {
+	this.node = WebBuildCode(
+		this.node,
+		parent,
+		this.view,
+		this.language,
+		this.content,
+	)
+}
+
+func (this *CodeComponentWeb) Invalidate() bool {
+	result := this.change
+	this.change = false
+	return result
+}
+
+func (this *CodeComponentWeb) Clean() {
+	WebCleanChildren(this.node)
+	this.change = true
+}
+
+// JS
+
+func (this *CodeComponentWeb) Node() js.Value {
+	return this.node
+}
+
+// ComponentCode
+
+func (this *CodeComponentWeb) SetCode(language string, content string) {
+	this.language = language
+	this.content = content
+}

+ 50 - 0
component_web_container.go

@@ -0,0 +1,50 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"syscall/js"
+)
+
+type ContainerComponentWeb struct {
+	node   js.Value
+	view   View
+	change bool
+}
+
+func NewContainerComponentWeb(view View) *ContainerComponentWeb {
+	return &ContainerComponentWeb{
+		view:   view,
+		change: true,
+	}
+}
+
+// Component
+
+func (this *ContainerComponentWeb) Build(parent Component) {
+	this.node = WebBuildDiv(
+		this.node,
+		parent,
+		this.view,
+		"",
+		"",
+		AbstractHandlers{},
+	)
+}
+
+func (this *ContainerComponentWeb) Invalidate() bool {
+	result := this.change
+	this.change = false
+	return result
+}
+
+func (this *ContainerComponentWeb) Clean() {
+	WebCleanChildren(this.node)
+	this.change = true
+}
+
+// JS
+
+func (this *ContainerComponentWeb) Node() js.Value {
+	return this.node
+}

+ 55 - 0
component_web_frame.go

@@ -0,0 +1,55 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"syscall/js"
+)
+
+type FrameComponentWeb struct {
+	node   js.Value
+	view   View
+	change bool
+	url    string
+}
+
+func NewFrameComponentWeb(view View) *FrameComponentWeb {
+	return &FrameComponentWeb{
+		view:   view,
+		change: true,
+		url:    "",
+	}
+}
+
+// Component
+
+func (this *FrameComponentWeb) Build(parent Component) {
+	this.node = WebBuildFrame(
+		this.node,
+		parent,
+		this.view,
+		this.url,
+	)
+}
+
+func (this *FrameComponentWeb) Invalidate() bool {
+	result := this.change
+	this.change = false
+	return result
+}
+
+func (this *FrameComponentWeb) Clean() {
+	WebCleanChildren(this.node)
+}
+
+// JS
+
+func (this *FrameComponentWeb) Node() js.Value {
+	return this.node
+}
+
+// ComponentFrame
+
+func (this *FrameComponentWeb) SetUrl(url string) {
+	this.url = url
+}

+ 163 - 0
component_web_helper.go

@@ -0,0 +1,163 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"syscall/js"
+)
+
+func WebWindow() js.Value {
+	return js.Global().Get("window")
+}
+
+func WebDocument() js.Value {
+	return js.Global().Get("document")
+}
+
+func WebContent() js.Value {
+	return WebDocument().
+		Call(
+			"getElementById",
+			"content",
+		)
+}
+
+func WebBuildDiv(oldNode js.Value, parent Component, view View, text string, html string, handlers AbstractHandlers) js.Value {
+	return WebBuildDivInternal(
+		oldNode,
+		parent.(JS).Node(),
+		view,
+		text,
+		html,
+		handlers,
+	)
+}
+
+func WebBuildDivInternal(oldNode js.Value, parent js.Value, view View, text string, html string, handlers AbstractHandlers) js.Value {
+	newNode := WebDocument().Call("createElement", "div")
+	newNode.Call("setAttribute", "style", ViewToString(view))
+
+	if text != "" {
+		newNode.Set("innerText", text)
+	}
+
+	if html != "" {
+		newNode.Set("innerHTML", html)
+	}
+
+	for key, handler := range handlers {
+		switch key {
+		case HANDLER_CLICK:
+			newNode.Call(
+				"addEventListener",
+				"click",
+				js.FuncOf(
+					func(current js.Value, args []js.Value) interface{} {
+						handler(nil)
+						return nil
+					},
+				),
+			)
+		}
+
+	}
+
+	if parent.Call("contains", oldNode).Bool() {
+		parent.Call(
+			"replaceChild",
+			newNode,
+			oldNode,
+		)
+	} else {
+		parent.Call(
+			"appendChild",
+			newNode,
+		)
+	}
+
+	return newNode
+}
+
+func WebBuildImg(oldNode js.Value, parentComponent Component, view View, path string) js.Value {
+	parent := parentComponent.(JS).Node()
+
+	newNode := WebDocument().Call("createElement", "img")
+	newNode.Call("setAttribute", "src", path)
+	newNode.Call("setAttribute", "style", ViewToString(view))
+
+	if parent.Call("contains", oldNode).Bool() {
+		parent.Call(
+			"replaceChild",
+			newNode,
+			oldNode,
+		)
+	} else {
+		parent.Call(
+			"appendChild",
+			newNode,
+		)
+	}
+
+	return newNode
+}
+
+func WebBuildFrame(oldNode js.Value, parentComponent Component, view View, path string) js.Value {
+	parent := parentComponent.(JS).Node()
+
+	newNode := WebDocument().Call("createElement", "iframe")
+	newNode.Call("setAttribute", "src", path)
+	newNode.Call("setAttribute", "style", ViewToString(view))
+
+	if parent.Call("contains", oldNode).Bool() {
+		parent.Call(
+			"replaceChild",
+			newNode,
+			oldNode,
+		)
+	} else {
+		parent.Call(
+			"appendChild",
+			newNode,
+		)
+	}
+
+	return newNode
+}
+
+func WebBuildCode(oldNode js.Value, parentComponent Component, view View, language string, content string) js.Value {
+	parent := parentComponent.(JS).Node()
+
+	codeNode := WebDocument().Call("createElement", "code")
+	codeNode.Call("setAttribute", "class", "language-"+language)
+	codeNode.Set("textContent", content)
+
+	newNode := WebDocument().Call("createElement", "pre")
+	newNode.Call("setAttribute", "style", ViewToString(view))
+	newNode.Call(
+		"appendChild",
+		codeNode,
+	)
+
+	if parent.Call("contains", oldNode).Bool() {
+		parent.Call(
+			"replaceChild",
+			newNode,
+			oldNode,
+		)
+	} else {
+		parent.Call(
+			"appendChild",
+			newNode,
+		)
+	}
+
+	WebWindow().Call("HighlightCode")
+	return newNode
+}
+
+func WebCleanChildren(node js.Value) {
+	children := node.Get("children")
+	for i := range children.Length() {
+		node.Call("removeChild", children.Index(i))
+	}
+}

+ 55 - 0
component_web_image.go

@@ -0,0 +1,55 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"syscall/js"
+)
+
+type ImageComponentWeb struct {
+	node   js.Value
+	view   View
+	change bool
+	path   string
+}
+
+func NewImageComponentWeb(view View) *ImageComponentWeb {
+	return &ImageComponentWeb{
+		view:   view,
+		change: true,
+		path:   "",
+	}
+}
+
+// Component
+
+func (this *ImageComponentWeb) Build(parent Component) {
+	this.node = WebBuildImg(
+		this.node,
+		parent,
+		this.view,
+		this.path,
+	)
+}
+
+func (this *ImageComponentWeb) Invalidate() bool {
+	result := this.change
+	this.change = false
+	return result
+}
+
+func (this *ImageComponentWeb) Clean() {
+	WebCleanChildren(this.node)
+}
+
+// JS
+
+func (this *ImageComponentWeb) Node() js.Value {
+	return this.node
+}
+
+// ComponentImage
+
+func (this *ImageComponentWeb) SetPath(path string) {
+	this.path = path
+}

+ 9 - 0
component_web_js.go

@@ -0,0 +1,9 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import "syscall/js"
+
+type JS interface {
+	Node() js.Value
+}

+ 57 - 0
component_web_markdown.go

@@ -0,0 +1,57 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"syscall/js"
+)
+
+type MarkdownComponentWeb struct {
+	node    js.Value
+	view    View
+	change  bool
+	content string
+}
+
+func NewMarkdownComponentWeb(view View) *MarkdownComponentWeb {
+	return &MarkdownComponentWeb{
+		view:   view,
+		change: true,
+	}
+}
+
+// Component
+
+func (this *MarkdownComponentWeb) Build(parent Component) {
+	this.node = WebBuildDiv(
+		this.node,
+		parent,
+		this.view,
+		"",
+		MarkdownConvert(this.content),
+		AbstractHandlers{},
+	)
+}
+
+func (this *MarkdownComponentWeb) Invalidate() bool {
+	result := this.change
+	this.change = false
+	return result
+}
+
+func (this *MarkdownComponentWeb) Clean() {
+	WebCleanChildren(this.node)
+	this.change = true
+}
+
+// JS
+
+func (this *MarkdownComponentWeb) Node() js.Value {
+	return this.node
+}
+
+// ComponentMarkdown
+
+func (this *MarkdownComponentWeb) SetMarkdown(content string) {
+	this.content = content
+}

+ 44 - 0
component_web_root.go

@@ -0,0 +1,44 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"syscall/js"
+)
+
+type RootComponentWeb struct {
+	node js.Value
+}
+
+func NewRootComponentWeb(view View) *RootComponentWeb {
+	return &RootComponentWeb{
+		node: WebBuildDivInternal(
+			js.Value{},
+			WebContent(),
+			view,
+			"",
+			"",
+			AbstractHandlers{},
+		),
+	}
+}
+
+// Component
+
+func (this *RootComponentWeb) Build(parent Component) {
+	// ...
+}
+
+func (this *RootComponentWeb) Invalidate() bool {
+	return false
+}
+
+func (this *RootComponentWeb) Clean() {
+	WebCleanChildren(this.node)
+}
+
+// JS
+
+func (this *RootComponentWeb) Node() js.Value {
+	return this.node
+}

+ 268 - 0
css.go

@@ -0,0 +1,268 @@
+package ultraviolet
+
+import (
+	"fmt"
+	"strings"
+)
+
+func ViewToString(view View) string {
+	parts := []string{
+		DisplayToString(view.Block.Display),
+		PositionToString(view.Block.Position),
+		FloatToString(view.Block.Float),
+		WidthToString(view.Block.Width),
+		HeightToString(view.Block.Height),
+		BottomToString(view.Block.Bottom),
+		LeftToString(view.Block.Left),
+		TopToString(view.Block.Top),
+		RightToString(view.Block.Right),
+		MarginToString(view.Block.Margin),
+		PaddingToString(view.Block.Padding),
+		BorderThicknessToString(view.Block.Border),
+		BorderRadiusToString(view.Block.Border),
+		FontToString(view.Font),
+		view.Custom,
+	}
+
+	switch view.Cursor {
+	case CURSOR_POINTER:
+		parts = append(parts, "cursor: pointer;")
+		parts = append(parts, "user-select: none;")
+	}
+
+	return strings.Join(parts, "")
+}
+
+func DisplayToString(display int) string {
+	switch display {
+	case DISPLAY_BLOCK:
+		return "display: block;"
+	case DISPLAY_INLINE:
+		return "display: inline-block;"
+	}
+
+	return ""
+}
+
+func PositionToString(position int) string {
+	switch position {
+	case POSITION_FIXED:
+		return "position: fixed;"
+	case POSITION_RELATIVE:
+		return "position: relative;"
+	case POSITION_ABSOLUTE:
+		return "position: absolute;"
+	}
+
+	return ""
+}
+
+func FloatToString(float int) string {
+	switch float {
+	case FLOAT_LEFT:
+		return "float: left;"
+	case FLOAT_RIGHT:
+		return "float: right;"
+	case FLOAT_CLEAR:
+		return "clear: both;"
+	}
+
+	return ""
+}
+
+func WidthToString(width int) string {
+	if width == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf("width: %dpx;", width)
+}
+
+func HeightToString(height int) string {
+	if height == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf("height: %dpx;", height)
+}
+
+func BottomToString(bottom int) string {
+	if bottom == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf("bottom: %dpx;", bottom)
+}
+
+func LeftToString(left int) string {
+	if left == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf("left: %dpx;", left)
+}
+
+func TopToString(top int) string {
+	if top == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf("top: %dpx;", top)
+}
+
+func RightToString(right int) string {
+	if right == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf("right: %dpx;", right)
+}
+
+func MarginToString(margin Margin) string {
+	if margin.Top == 0 && margin.Right == 0 && margin.Bottom == 0 && margin.Left == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf(
+		"margin: %dpx %dpx %dpx %dpx;",
+		margin.Top,
+		margin.Right,
+		margin.Bottom,
+		margin.Left,
+	)
+}
+
+func PaddingToString(padding Padding) string {
+	if padding.Top == 0 && padding.Right == 0 && padding.Bottom == 0 && padding.Left == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf(
+		"padding: %dpx %dpx %dpx %dpx;",
+		padding.Top,
+		padding.Right,
+		padding.Bottom,
+		padding.Left,
+	)
+}
+
+func BorderThicknessToString(border Border) string {
+	color := ColorToString(border.Color)
+	parts := []string{}
+
+	if border.ThicknessTop != 0 {
+		parts = append(
+			parts,
+			fmt.Sprintf("border-top: %dpx solid %s;", border.ThicknessTop, color),
+		)
+	}
+
+	if border.ThicknessRight != 0 {
+		parts = append(
+			parts,
+			fmt.Sprintf("border-right: %dpx solid %s;", border.ThicknessRight, color),
+		)
+	}
+
+	if border.ThicknessBottom != 0 {
+		parts = append(
+			parts,
+			fmt.Sprintf("border-bottom: %dpx solid %s;", border.ThicknessBottom, color),
+		)
+	}
+
+	if border.ThicknessLeft != 0 {
+		parts = append(
+			parts,
+			fmt.Sprintf("border-left: %dpx solid %s;", border.ThicknessLeft, color),
+		)
+	}
+
+	return strings.Join(parts, "")
+}
+
+func BorderRadiusToString(border Border) string {
+	if border.RadiusTopLeft == 0 && border.RadiusTopRight == 0 && border.RadiusBottomRight == 0 && border.RadiusBottomLeft == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf(
+		"border-radius: %dpx %dpx %dpx %dpx;",
+		border.RadiusTopLeft,
+		border.RadiusTopRight,
+		border.RadiusBottomRight,
+		border.RadiusBottomLeft,
+	)
+}
+
+func ColorToString(color Color) string {
+	if color.Alpha == 0 {
+		return ""
+	}
+
+	return fmt.Sprintf(
+		"rgba(%d, %d, %d, %.2f)",
+		color.Red,
+		color.Green,
+		color.Blue,
+		float64(color.Alpha)/255,
+	)
+}
+
+func FontToString(font Font) string {
+	parts := []string{}
+
+	if font.Face != "" {
+		parts = append(
+			parts,
+			fmt.Sprintf("font-face: '%s';", font.Face),
+		)
+	}
+
+	if font.Size != 0 {
+		parts = append(
+			parts,
+			fmt.Sprintf("font-size: %dpx;", font.Size),
+		)
+	}
+
+	if font.Bold {
+		parts = append(
+			parts,
+			"font-weight: bold;",
+		)
+	}
+
+	if font.Italic {
+		parts = append(
+			parts,
+			"font-view: italic;",
+		)
+	}
+
+	switch font.Decoration {
+	case DECORATION_UNDERLINE:
+		parts = append(
+			parts,
+			"text-decoration: underline;",
+		)
+	}
+
+	fontColor := ColorToString(font.FontColor)
+	if fontColor != "" {
+		parts = append(
+			parts,
+			fmt.Sprintf("color: %s;", ColorToString(font.FontColor)),
+		)
+	}
+
+	backgroundColor := ColorToString(font.BackgroundColor)
+	if backgroundColor != "" {
+		parts = append(
+			parts,
+			fmt.Sprintf("background-color: %s;", ColorToString(font.BackgroundColor)),
+		)
+	}
+
+	return strings.Join(parts, "")
+}

+ 5 - 0
error.go

@@ -0,0 +1,5 @@
+package ultraviolet
+
+import "errors"
+
+var ErrIdInvalid = errors.New("id invalid")

+ 31 - 0
factory.go

@@ -0,0 +1,31 @@
+//go:build js && wasm
+
+package ultraviolet
+
+type Model map[string]any
+type Controller map[string]any
+
+type ComponentOptions struct {
+	View       View
+	Model      Model
+	Controller Controller
+}
+
+type Factory interface {
+	NewRoot(options any) Component
+	NewContainer(options any) Component
+	NewButton(options any) Component
+	NewImage(options any) Component
+	NewMarkdown(options any) Component
+	NewFrame(options any) Component
+	NewCode(options any) Component
+}
+
+func NewFactory(platform int) Factory {
+	switch platform {
+	case PLATFORM_WEB:
+		return NewWebFactory()
+	default:
+		return NewWebFactory()
+	}
+}

+ 5 - 0
factory_const.go

@@ -0,0 +1,5 @@
+package ultraviolet
+
+const PLATFORM_WEB = 0
+const PLATFORM_ANDROID = 1
+const PLATFORM_IOS = 2

+ 85 - 0
factory_web.go

@@ -0,0 +1,85 @@
+//go:build js && wasm
+
+package ultraviolet
+
+type WebFactory struct {
+}
+
+func NewWebFactory() Factory {
+	return &WebFactory{}
+}
+
+func (this *WebFactory) NewRoot(options any) Component {
+	optionsRoot := options.(RootComponentOptions)
+	return NewRootComponentWeb(optionsRoot.View)
+}
+
+func (this *WebFactory) NewContainer(options any) Component {
+	optionsContainer := options.(ContainerComponentOptions)
+	return NewContainerComponentWeb(optionsContainer.View)
+}
+
+func (this *WebFactory) NewButton(options any) Component {
+	optionsButton := options.(ButtonComponentOptions)
+	result := NewButtonComponentWeb(optionsButton.View)
+
+	if optionsButton.Controller.Handler != nil {
+		result.SetHandler(optionsButton.Controller.Handler)
+	}
+
+	if optionsButton.Model.Format != "" && optionsButton.Model.Values != nil {
+		result.SetValue(
+			optionsButton.Model.Format,
+			optionsButton.Model.Values,
+		)
+	}
+
+	return result
+}
+
+func (this *WebFactory) NewImage(options any) Component {
+	optionsImage := options.(ImageComponentOptions)
+	result := NewImageComponentWeb(optionsImage.View)
+
+	if optionsImage.Model.Path != "" {
+		result.SetPath(optionsImage.Model.Path)
+	}
+
+	return result
+}
+
+func (this *WebFactory) NewMarkdown(options any) Component {
+	optionsMarkdown := options.(MarkdownComponentOptions)
+	result := NewMarkdownComponentWeb(optionsMarkdown.View)
+
+	if optionsMarkdown.Model.Content != "" {
+		result.SetMarkdown(optionsMarkdown.Model.Content)
+	}
+
+	return result
+}
+
+func (this *WebFactory) NewFrame(options any) Component {
+	optionsFrame := options.(FrameComponentOptions)
+	result := NewFrameComponentWeb(optionsFrame.View)
+
+	if optionsFrame.Model.URL != "" {
+		result.SetUrl(optionsFrame.Model.URL)
+	}
+
+	return result
+}
+
+func (this *WebFactory) NewCode(options any) Component {
+	optionsCode := options.(CodeComponentOptions)
+	result := NewCodeComponentWeb(optionsCode.View)
+
+	if optionsCode.Model.Language != "" && optionsCode.Model.Content != "" {
+		result.SetCode(
+			optionsCode.Model.Language,
+			optionsCode.Model.Content,
+		)
+	}
+
+	return result
+}

+ 10 - 0
go.mod

@@ -0,0 +1,10 @@
+module git.buran.team/ultraviolet
+
+go 1.26.0
+
+require (
+	git.buran.team/fairwind v0.0.0
+	github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
+)
+
+replace git.buran.team/fairwind v0.0.0 => ../fairwind

+ 2 - 0
go.sum

@@ -0,0 +1,2 @@
+github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
+github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=

+ 24 - 0
markdown.go

@@ -0,0 +1,24 @@
+package ultraviolet
+
+import (
+	"github.com/gomarkdown/markdown"
+	"github.com/gomarkdown/markdown/html"
+	"github.com/gomarkdown/markdown/parser"
+)
+
+func MarkdownConvert(md string) string {
+	return string(
+		markdown.Render(
+			parser.NewWithExtensions(
+				parser.CommonExtensions|parser.AutoHeadingIDs|parser.NoEmptyLineBeforeBlock,
+			).Parse(
+				[]byte(md),
+			),
+			html.NewRenderer(
+				html.RendererOptions{
+					Flags: html.CommonFlags | html.HrefTargetBlank,
+				},
+			),
+		),
+	)
+}

+ 25 - 0
platform_web_helper.go

@@ -0,0 +1,25 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import "syscall/js"
+
+func WebSetHandlers(handlerCreate func([]js.Value, js.Value, js.Value), handlerUpdate func([]js.Value, js.Value, js.Value)) {
+	js.Global().Set("Create", webWrap(handlerCreate))
+	js.Global().Set("Update", webWrap(handlerUpdate))
+}
+
+func webWrap(handler func([]js.Value, js.Value, js.Value)) js.Func {
+	wrapper := func(this js.Value, args1 []js.Value) interface{} {
+		return js.Global().Get("Promise").New(
+			js.FuncOf(
+				func(this js.Value, args2 []js.Value) interface{} {
+					go handler(args1, args2[0], args2[1])
+					return nil
+				},
+			),
+		)
+	}
+
+	return js.FuncOf(wrapper)
+}

+ 117 - 0
reactive_queue.go

@@ -0,0 +1,117 @@
+package ultraviolet
+
+import (
+	"context"
+	"sync"
+	"time"
+)
+
+type State struct {
+	globalCtx context.Context
+	localCtx  context.Context
+	cancel    func()
+	mutex     sync.RWMutex
+	waitGroup sync.WaitGroup
+	eventsIn  chan any
+	eventsOut chan any
+	queue     []any
+}
+
+func NewStateWithOpts(globalCtx context.Context) *State {
+	localCtx, cancel := context.WithCancel(context.Background())
+	return &State{
+		globalCtx: globalCtx,
+		localCtx:  localCtx,
+		cancel:    cancel,
+		eventsIn:  make(chan any),
+		eventsOut: make(chan any),
+		queue:     []any{},
+	}
+}
+
+func (this *State) Start() error {
+	this.waitGroup.Add(2)
+	go this.workerIn()
+	go this.workerOut()
+	return nil
+}
+
+func (this *State) Stop() error {
+	this.cancel()
+	close(this.eventsIn)
+	close(this.eventsOut)
+	this.waitGroup.Wait()
+	return nil
+}
+
+func (this *State) In() chan<- any {
+	return this.eventsIn
+}
+
+func (this *State) Out() <-chan any {
+	return this.eventsOut
+}
+
+func (this *State) enqueue(event any) {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	this.queue = append(this.queue, event)
+}
+
+func (this *State) dequeue() any {
+	this.mutex.RLock()
+	defer this.mutex.RUnlock()
+
+	if len(this.queue) == 0 {
+		return nil
+	}
+
+	result := this.queue[0]
+	this.queue = this.queue[1:]
+	return result
+}
+
+func (this *State) workerIn() {
+	defer this.waitGroup.Done()
+
+	for {
+		select {
+		case <-this.globalCtx.Done():
+			return
+
+		case <-this.localCtx.Done():
+			return
+
+		case event, ok := <-this.eventsIn:
+			if !ok {
+				return
+			}
+
+			this.enqueue(event)
+			continue
+		}
+
+	}
+}
+
+func (this *State) workerOut() {
+	defer this.waitGroup.Done()
+
+	for {
+		select {
+		case <-this.localCtx.Done():
+			return
+		case <-time.After(1 * time.Microsecond):
+		}
+
+		event := this.dequeue()
+		if event == nil {
+			continue
+		}
+
+		go func(event any) {
+			this.eventsOut <- event
+		}(event)
+	}
+}

+ 3 - 0
reactive_selector.go

@@ -0,0 +1,3 @@
+package ultraviolet
+
+// TODO: move to context

+ 96 - 0
reactive_source.go

@@ -0,0 +1,96 @@
+package ultraviolet
+
+import (
+	"context"
+	"sync"
+)
+
+type Subscribers map[any]chan any
+
+type Source struct {
+	globalCtx   context.Context
+	localCtx    context.Context
+	cancel      func()
+	mutex       sync.Mutex
+	waitGroup   sync.WaitGroup
+	queue       *State
+	subscribers Subscribers
+}
+
+func NewSource(globalCtx context.Context, queue *State) *Source {
+	localCtx, cancel := context.WithCancel(context.Background())
+	return &Source{
+		globalCtx:   globalCtx,
+		localCtx:    localCtx,
+		cancel:      cancel,
+		queue:       queue,
+		subscribers: Subscribers{},
+	}
+}
+
+func (this *Source) Start() error {
+	this.waitGroup.Add(1)
+	go this.worker()
+	return nil
+}
+
+func (this *Source) Stop() error {
+	this.cancel()
+	for _, subscriber := range this.subscribers {
+		close(subscriber)
+	}
+
+	this.waitGroup.Wait()
+	return nil
+}
+
+func (this *Source) Subscribe(key any) <-chan any {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	result := make(chan any)
+	this.subscribers[key] = result
+	return result
+}
+
+func (this *Source) Unsubscribe(key any) error {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	_, ok := this.subscribers[key]
+	if !ok {
+		return ErrIdInvalid
+	}
+
+	delete(this.subscribers, key)
+	return nil
+}
+
+func (this *Source) worker() {
+	defer this.waitGroup.Done()
+
+	for {
+		select {
+		case <-this.globalCtx.Done():
+			return
+
+		case <-this.localCtx.Done():
+			return
+
+		case event, ok := <-this.queue.Out():
+			if !ok {
+				continue
+			}
+
+			this.mutex.Lock()
+
+			for _, subscriber := range this.subscribers {
+				go func(subscriber chan any, event any) {
+					subscriber <- event
+				}(subscriber, event)
+			}
+
+			this.mutex.Unlock()
+		}
+	}
+}

+ 8 - 0
reactive_value.go

@@ -0,0 +1,8 @@
+package ultraviolet
+
+type Value interface {
+	Start() error
+	Stop() error
+	Value() any
+	Invalidate() bool
+}

+ 81 - 0
reactive_value_reactive.go

@@ -0,0 +1,81 @@
+package ultraviolet
+
+import (
+	"context"
+	"fmt"
+	"sync"
+)
+
+type ValueReactive struct {
+	ctxGlobal context.Context
+	ctxLocal  context.Context
+	cancel    func()
+	waitGroup sync.WaitGroup
+	source    *Source
+	out       <-chan any
+	selector  Selector
+	value     any
+	change    bool
+}
+
+func NewValueReactive(ctxGlobal context.Context, source *Source, selector Selector) *ValueReactive {
+	ctxLocal, cancel := context.WithCancel(context.Background())
+	return &ValueReactive{
+		ctxGlobal: ctxGlobal,
+		ctxLocal:  ctxLocal,
+		cancel:    cancel,
+		source:    source,
+		selector:  selector,
+		value:     nil,
+		change:    true,
+	}
+}
+
+func (this *ValueReactive) Start() error {
+	this.out = this.source.Subscribe(this)
+	this.waitGroup.Add(1)
+	go this.worker()
+	return nil
+}
+
+func (this *ValueReactive) Stop() error {
+	err := this.source.Unsubscribe(this)
+	if err != nil {
+		return fmt.Errorf("can't stop reactive value: %w", err)
+	}
+
+	this.cancel()
+	this.waitGroup.Wait()
+	return nil
+}
+
+func (this *ValueReactive) Value() any {
+	return this.value
+}
+
+func (this *ValueReactive) Invalidate() bool {
+	result := this.change
+	this.change = false
+	return result
+}
+
+func (this *ValueReactive) worker() {
+	defer this.waitGroup.Done()
+
+	for {
+		select {
+		case <-this.ctxLocal.Done():
+			return
+		case event, ok := <-this.out:
+			if !ok {
+				return
+			}
+
+			value := this.selector(event).Value()
+			if this.value != value {
+				this.value = value
+				this.change = true
+			}
+		}
+	}
+}

+ 27 - 0
reactive_value_static.go

@@ -0,0 +1,27 @@
+package ultraviolet
+
+type ValueStatic struct {
+	value any
+}
+
+func NewValueStatic(value any) *ValueStatic {
+	return &ValueStatic{
+		value: value,
+	}
+}
+
+func (this *ValueStatic) Start() error {
+	return nil
+}
+
+func (this *ValueStatic) Stop() error {
+	return nil
+}
+
+func (this *ValueStatic) Value() any {
+	return this.value
+}
+
+func (this *ValueStatic) Invalidate() bool {
+	return false
+}

+ 81 - 0
sq.go

@@ -0,0 +1,81 @@
+package ultraviolet
+
+import (
+	"fmt"
+	"reflect"
+)
+
+type SQ struct {
+	value reflect.Value
+}
+
+func NewSQ(value any) *SQ {
+	return &SQ{
+		value: reflect.ValueOf(value).Elem(),
+	}
+}
+
+func newSQInternal(value reflect.Value) *SQ {
+	return &SQ{
+		value: value,
+	}
+}
+
+func (this *SQ) Field(name string) *SQ {
+	if this.value.Kind() != reflect.Struct {
+		return nil
+	}
+
+	return newSQInternal(
+		this.value.FieldByName(name),
+	)
+}
+
+func (this *SQ) Length() int {
+	return this.value.Len()
+}
+
+func (this *SQ) Index(index int) *SQ {
+	if this.value.Kind() != reflect.Slice {
+		return nil
+	}
+
+	return newSQInternal(
+		this.value.Index(index),
+	)
+}
+
+func (this *SQ) Keys() []string {
+	result := []string{}
+	for _, key := range this.value.MapKeys() {
+		result = append(result, key.String())
+	}
+
+	return result
+}
+
+func (this *SQ) Key(key string) *SQ {
+	if this.value.Kind() != reflect.Map {
+		return nil
+	}
+
+	return newSQInternal(
+		this.value.MapIndex(reflect.ValueOf(key)),
+	)
+}
+
+func (this *SQ) Value() any {
+	if this.value.Kind() == reflect.Int {
+		return fmt.Sprintf("%d", this.value.Int())
+	}
+
+	return this.value.String()
+}
+
+func (this *SQ) ValueInt() any {
+	return this.value.Int()
+}
+
+func (this *SQ) ValueString() any {
+	return this.value.String()
+}

+ 34 - 0
sq_selector.go

@@ -0,0 +1,34 @@
+package ultraviolet
+
+import (
+	"strconv"
+	"strings"
+)
+
+type Selector func(expression any) *SQ
+
+func ExpressionToSelector(expression string) Selector {
+	return func(value any) *SQ {
+		// TODO: lock value mutex
+		parts := strings.Split(expression, ".")
+		result := NewSQ(value)
+
+		for _, part := range parts {
+			index, err := strconv.Atoi(part)
+			if err == nil {
+				result = result.Index(index)
+				continue
+			}
+
+			resultField := result.Field(part)
+			if resultField != nil {
+				result = resultField
+				continue
+			}
+
+			result = result.Key(part)
+		}
+
+		return result
+	}
+}

+ 10 - 0
style.go

@@ -0,0 +1,10 @@
+package ultraviolet
+
+const CURSOR_POINTER = 1
+
+type View struct {
+	Block  Block
+	Font   Font
+	Cursor int
+	Custom string
+}

+ 53 - 0
style_block.go

@@ -0,0 +1,53 @@
+package ultraviolet
+
+const DISPLAY_BLOCK = 0
+const DISPLAY_INLINE = 1
+
+const POSITION_FIXED = 1
+const POSITION_RELATIVE = 2
+const POSITION_ABSOLUTE = 3
+
+const FLOAT_LEFT = 1
+const FLOAT_RIGHT = 2
+const FLOAT_CLEAR = 3
+
+type Margin struct {
+	Bottom int
+	Left   int
+	Top    int
+	Right  int
+}
+
+type Padding struct {
+	Bottom int
+	Left   int
+	Top    int
+	Right  int
+}
+
+type Border struct {
+	ThicknessBottom   int
+	ThicknessLeft     int
+	ThicknessTop      int
+	ThicknessRight    int
+	RadiusBottomLeft  int
+	RadiusTopLeft     int
+	RadiusTopRight    int
+	RadiusBottomRight int
+	Color             Color
+}
+
+type Block struct {
+	Display  int
+	Position int
+	Float    int
+	Width    int
+	Height   int
+	Bottom   int
+	Left     int
+	Top      int
+	Right    int
+	Margin   Margin
+	Padding  Padding
+	Border   Border
+}

+ 8 - 0
style_color.go

@@ -0,0 +1,8 @@
+package ultraviolet
+
+type Color struct {
+	Red   int
+	Green int
+	Blue  int
+	Alpha int
+}

+ 13 - 0
style_font.go

@@ -0,0 +1,13 @@
+package ultraviolet
+
+const DECORATION_UNDERLINE = 1
+
+type Font struct {
+	Face            string
+	Size            int
+	Bold            bool
+	Italic          bool
+	Decoration      int
+	FontColor       Color
+	BackgroundColor Color
+}

+ 7 - 0
tree_error.go

@@ -0,0 +1,7 @@
+package ultraviolet
+
+import "errors"
+
+var ErrNoDefaultRoute = errors.New("no default route")
+var ErrRouteNotFound = errors.New("route not found")
+var ErrStackUnderflow = errors.New("stack underflow")

+ 51 - 0
tree_node.go

@@ -0,0 +1,51 @@
+package ultraviolet
+
+import (
+	"sync"
+)
+
+type NodeList []*Node
+type NodeMap map[string]*Node
+type NodeIds map[string]int
+
+type Node struct {
+	mutex     sync.Mutex
+	Component Component
+	Children  NodeList
+}
+
+func NewNode(component Component) *Node {
+	return &Node{
+		Children:  NodeList{},
+		Component: component,
+	}
+}
+
+func (this *Node) Build(node *Node) {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	this.Component.Clean()
+	this.Children = NodeList{node}
+	for _, child := range this.Children {
+		child.update(this.Component, true)
+	}
+}
+
+func (this *Node) Update() {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	this.update(nil, false)
+}
+
+func (this *Node) update(parent Component, force bool) {
+	if this.Component.Invalidate() || force {
+		this.Component.Build(parent)
+		force = true
+	}
+
+	for _, item := range this.Children {
+		item.update(this.Component, force)
+	}
+}

+ 172 - 0
tree_router.go

@@ -0,0 +1,172 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"context"
+	"fmt"
+	"sync"
+
+	fw "git.buran.team/fairwind"
+)
+
+type Parameters map[string]any
+type NodeBuilder func(uvc *UVContext, parameters Parameters) (*Node, error)
+
+type Route struct {
+	Default    bool
+	Builder    NodeBuilder
+	name       string     // NOTE: sets automatically
+	parameters Parameters // NOTE: sets automatically
+}
+
+type Routing map[string]*Route
+
+type Router struct {
+	ctx     context.Context
+	log     *fw.Log
+	mutex   sync.Mutex
+	uvc     *UVContext
+	root    *Node
+	routing Routing
+	stack   []Route
+}
+
+func NewRouter(ctx context.Context, log *fw.Log, uvc *UVContext, routing Routing) (*Router, error) {
+	defaultRoute := ""
+	for name := range routing {
+		if routing[name].Default {
+			defaultRoute = name
+		}
+
+		routing[name].name = name
+	}
+
+	if defaultRoute == "" {
+		return nil, ErrNoDefaultRoute
+	}
+
+	this := &Router{
+		ctx: ctx,
+		log: log,
+		uvc: uvc,
+		root: &Node{
+			Component: uvc.Factory().NewRoot(
+				RootComponentOptions{},
+			),
+		},
+		routing: routing,
+		stack:   []Route{},
+	}
+	this.Push(defaultRoute, Parameters{}) // NOTE: default router haven't parameters
+	return this, nil
+}
+
+func (this *Router) Update() {
+	this.root.Update()
+}
+
+func (this *Router) Push(name string, parameters Parameters) error {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	route, ok := this.routing[name]
+	if !ok {
+		return ErrRouteNotFound
+	}
+
+	this.stack = append(
+		this.stack,
+		Route{
+			name:       name,
+			parameters: parameters,
+			Builder:    route.Builder,
+		},
+	)
+
+	go func() {
+		err := this.refresh()
+		if err != nil {
+			this.log.Error("can't refresh", fw.LogError(err))
+		}
+	}()
+
+	return nil
+}
+
+func (this *Router) Pop() (Route, error) {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	if len(this.stack) == 0 {
+		return Route{}, ErrStackUnderflow
+	}
+
+	result := this.stack[len(this.stack)-1]
+	this.stack = this.stack[:len(this.stack)-1]
+
+	go func() {
+		err := this.refresh()
+		if err != nil {
+			this.log.Error("can't refresh", fw.LogError(err))
+		}
+	}()
+
+	return result, nil
+}
+
+func (this *Router) Back() {
+	_, err := this.Pop()
+	if err != nil {
+		this.log.Error("can't back on router stack", fw.LogError(err))
+	}
+}
+
+func (this *Router) Set(name string, parameters Parameters) error {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	route, ok := this.routing[name]
+	if !ok {
+		return ErrRouteNotFound
+	}
+
+	this.stack = []Route{
+		{
+			name:       name,
+			parameters: parameters,
+			Builder:    route.Builder,
+		},
+	}
+
+	go func() {
+		err := this.refresh()
+		if err != nil {
+			this.log.Error("can't refresh", fw.LogError(err))
+		}
+	}()
+
+	return nil
+}
+
+func (this *Router) refresh() error {
+	if len(this.stack) == 0 {
+		return ErrStackUnderflow
+	}
+
+	// Get route
+	route := this.stack[len(this.stack)-1]
+
+	// Build new window
+	child, err := route.Builder(
+		this.uvc,
+		route.parameters,
+	)
+	if err != nil {
+		return fmt.Errorf("can't refresh router: %w", err)
+	}
+
+	// Set new window
+	this.root.Build(child)
+	return nil
+}

+ 105 - 0
uv_context.go

@@ -0,0 +1,105 @@
+//go:build js && wasm
+
+package ultraviolet
+
+import (
+	"context"
+	"fmt"
+	"sync"
+
+	fw "git.buran.team/fairwind"
+)
+
+type UVContext struct {
+	ctx     context.Context
+	log     *fw.Log
+	mutex   sync.Mutex
+	factory Factory
+	state   *State
+	source  *Source
+	values  []Value
+	router  *Router
+}
+
+func NewUVContext(ctx context.Context, log *fw.Log, platform int, routing Routing) (*UVContext, error) {
+	this := &UVContext{
+		ctx:     ctx,
+		factory: NewFactory(platform),
+		state:   NewStateWithOpts(ctx),
+		values:  []Value{},
+	}
+
+	router, err := NewRouter(ctx, log, this, routing)
+	if err != nil {
+		return nil, fmt.Errorf("can't create uv-context: %w", err)
+	}
+
+	this.router = router
+	this.source = NewSource(ctx, this.state)
+	return this, nil
+}
+
+func (this *UVContext) Start() error {
+	err := this.state.Start()
+	if err != nil {
+		return fmt.Errorf("can't start context: %w", err)
+	}
+
+	err = this.source.Start()
+	if err != nil {
+		return fmt.Errorf("can't start context: %w", err)
+	}
+
+	return nil
+}
+
+func (this *UVContext) Stop() error {
+	err := this.state.Stop()
+	if err != nil {
+		return fmt.Errorf("can't stop context: %w", err)
+	}
+
+	err = this.source.Stop()
+	if err != nil {
+		return fmt.Errorf("can't stop context: %w", err)
+	}
+
+	for _, value := range this.values {
+		err = value.Stop()
+		if err != nil {
+			return fmt.Errorf("can't stop context: %w", err)
+		}
+	}
+
+	return nil
+}
+
+func (this *UVContext) Update() {
+	this.router.Update()
+}
+
+func (this *UVContext) Factory() Factory {
+	return this.factory
+}
+
+func (this *UVContext) Router() *Router {
+	return this.router
+}
+
+func (this *UVContext) State() *State {
+	return this.state
+}
+
+func (this *UVContext) Value(expression string) (Value, error) {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	result := NewValueReactive(this.ctx, this.source, ExpressionToSelector(expression))
+	err := result.Start()
+	if err != nil {
+		return nil, fmt.Errorf("can't create value: %w", err)
+	}
+
+	this.values = append(this.values, result)
+	return result, nil
+}