diff --git a/Scripts/B9/CSSParser.gd b/Scripts/B9/CSSParser.gd index b89e75c..49873ca 100644 --- a/Scripts/B9/CSSParser.gd +++ b/Scripts/B9/CSSParser.gd @@ -6,6 +6,8 @@ class CSSRule: var event_prefix: String = "" var properties: Dictionary = {} var specificity: int = 0 + var selector_type: String = "simple" # simple, descendant, child, adjacent_sibling, general_sibling, attribute + var selector_parts: Array = [] # For complex selectors func init(sel: String = ""): selector = sel @@ -18,11 +20,52 @@ class CSSRule: if parts.size() == 2: selector = parts[0] event_prefix = parts[1] + + # Parse complex selectors + if selector.contains(" > "): + selector_type = "child" + selector_parts = selector.split(" > ") + elif selector.contains(" + "): + selector_type = "adjacent_sibling" + selector_parts = selector.split(" + ") + elif selector.contains(" ~ "): + selector_type = "general_sibling" + selector_parts = selector.split(" ~ ") + elif selector.contains("["): + selector_type = "attribute" + parse_attribute_selector() + elif selector.contains(" "): + selector_type = "descendant" + selector_parts = selector.split(" ") + else: + selector_type = "simple" + selector_parts = [selector] + + func parse_attribute_selector(): + var bracket_start = selector.find("[") + var bracket_end = selector.find("]") + if bracket_start != -1 and bracket_end != -1: + var element_part = selector.substr(0, bracket_start) + var attribute_part = selector.substr(bracket_start + 1, bracket_end - bracket_start - 1) + selector_parts = [element_part, attribute_part] func calculate_specificity(): specificity = 1 if selector.begins_with("."): - specificity += 10 # Class selectors have higher specificity than tag selectors + specificity += 10 + if selector.contains("["): + specificity += 10 # Attribute selectors + match selector_type: + "child": + specificity += 8 + "adjacent_sibling": + specificity += 7 + "attribute": + specificity += 6 + "general_sibling": + specificity += 5 + "descendant": + specificity += 4 if event_prefix.length() > 0: specificity += 10 @@ -32,13 +75,13 @@ class CSSStylesheet: func add_rule(rule: CSSRule): rules.append(rule) - func get_styles_for_element(tag_name: String, event: String = "", class_names: Array[String] = []) -> Dictionary: + func get_styles_for_element(tag_name: String, event: String = "", class_names: Array[String] = [], element: HTMLParser.HTMLElement = null) -> Dictionary: var styles = {} # Sort rules by specificity var applicable_rules: Array[CSSRule] = [] for rule in rules: - if selector_matches(rule, tag_name, event, class_names): + if selector_matches(rule, tag_name, event, class_names, element): applicable_rules.append(rule) applicable_rules.sort_custom(func(a, b): return a.specificity < b.specificity) @@ -50,21 +93,175 @@ class CSSStylesheet: return styles - func selector_matches(rule: CSSRule, tag_name: String, event: String = "", cls_names: Array[String] = []) -> bool: - # Handle class selectors - if rule.selector.begins_with("."): - var cls = rule.selector.substr(1) # Remove the "." prefix - if not cls in cls_names: - return false - else: - # Handle tag selectors - if rule.selector != tag_name: - return false - + func selector_matches(rule: CSSRule, tag_name: String, event: String = "", cls_names: Array[String] = [], element: HTMLParser.HTMLElement = null) -> bool: if rule.event_prefix.length() > 0: - return rule.event_prefix == event + if rule.event_prefix != event: + return false + elif event.length() > 0: + return false - return event.length() == 0 + match rule.selector_type: + "simple": + return matches_simple_selector(rule.selector_parts[0], tag_name, cls_names) + "descendant": + return matches_descendant_selector(rule.selector_parts, element) + "child": + return matches_child_selector(rule.selector_parts, element) + "adjacent_sibling": + return matches_adjacent_sibling_selector(rule.selector_parts, element) + "general_sibling": + return matches_general_sibling_selector(rule.selector_parts, element) + "attribute": + return matches_attribute_selector(rule.selector_parts, tag_name, cls_names, element) + + return false + + func matches_simple_selector(selector: String, tag_name: String, cls_names: Array[String]) -> bool: + if selector.begins_with("."): + var cls = selector.substr(1) + return cls in cls_names + else: + return selector == tag_name + + func matches_descendant_selector(parts: Array, element: HTMLParser.HTMLElement) -> bool: + if not element or parts.size() < 2: + return false + + # Last part should match current element + var last_part = parts[-1].strip_edges() + if not matches_simple_selector(last_part, element.tag_name, get_element_class_names(element)): + return false + + # Check ancestors for remaining parts + var current_element = element.parent + var part_index = parts.size() - 2 + + while current_element and part_index >= 0: + var part = parts[part_index].strip_edges() + if matches_simple_selector(part, current_element.tag_name, get_element_class_names(current_element)): + part_index -= 1 + if part_index < 0: + return true + current_element = current_element.parent + + return false + + func matches_child_selector(parts: Array, element: HTMLParser.HTMLElement) -> bool: + if not element or not element.parent or parts.size() != 2: + return false + + var child_part = parts[1].strip_edges() + var parent_part = parts[0].strip_edges() + + # Element must match the child part + if not matches_simple_selector(child_part, element.tag_name, get_element_class_names(element)): + return false + + # Parent must match the parent part + return matches_simple_selector(parent_part, element.parent.tag_name, get_element_class_names(element.parent)) + + func matches_adjacent_sibling_selector(parts: Array, element: HTMLParser.HTMLElement) -> bool: + if not element or not element.parent or parts.size() != 2: + return false + + var second_part = parts[1].strip_edges() + var first_part = parts[0].strip_edges() + + if not matches_simple_selector(second_part, element.tag_name, get_element_class_names(element)): + return false + + # Find previous sibling + var siblings = element.parent.children + var element_index = siblings.find(element) + if element_index <= 0: + return false + + var prev_sibling = siblings[element_index - 1] + return matches_simple_selector(first_part, prev_sibling.tag_name, get_element_class_names(prev_sibling)) + + func matches_general_sibling_selector(parts: Array, element: HTMLParser.HTMLElement) -> bool: + if not element or not element.parent or parts.size() != 2: + return false + + var second_part = parts[1].strip_edges() + var first_part = parts[0].strip_edges() + + if not matches_simple_selector(second_part, element.tag_name, get_element_class_names(element)): + return false + + # Check all previous siblings + var siblings = element.parent.children + var element_index = siblings.find(element) + + for i in range(element_index): + var sibling = siblings[i] + if matches_simple_selector(first_part, sibling.tag_name, get_element_class_names(sibling)): + return true + + return false + + func matches_attribute_selector(parts: Array, tag_name: String, cls_names: Array[String], element: HTMLParser.HTMLElement) -> bool: + if not element or parts.size() != 2: + return false + + var element_part = parts[0].strip_edges() + var attribute_part = parts[1].strip_edges() + + # Check if element matches + if element_part != "" and not matches_simple_selector(element_part, tag_name, cls_names): + return false + + # Parse attribute condition + if attribute_part.contains("="): + var parsed = {} + var element_value = "" + + if attribute_part.contains("^="): + # Starts with + parsed = parse_attribute_value(attribute_part, "^=") + element_value = element.get_attribute(parsed.name) + return element_value.begins_with(parsed.value) + elif attribute_part.contains("$="): + # Ends with + parsed = parse_attribute_value(attribute_part, "$=") + element_value = element.get_attribute(parsed.name) + return element_value.ends_with(parsed.value) + elif attribute_part.contains("*="): + # Contains + parsed = parse_attribute_value(attribute_part, "*=") + element_value = element.get_attribute(parsed.name) + return element_value.contains(parsed.value) + else: + # Exact match + parsed = parse_attribute_value(attribute_part, "=") + return element.get_attribute(parsed.name) == parsed.value + else: + # Just check if attribute exists + return element.has_attribute(attribute_part) + + func parse_attribute_value(attribute_part: String, operator: String) -> Dictionary: + var attr_parts = attribute_part.split(operator) + var attr_name = attr_parts[0].strip_edges() + var attr_value = attr_parts[1].strip_edges() + + # Remove quotes + if attr_value.begins_with('"') and attr_value.ends_with('"'): + attr_value = attr_value.substr(1, attr_value.length() - 2) + elif attr_value.begins_with("'") and attr_value.ends_with("'"): + attr_value = attr_value.substr(1, attr_value.length() - 2) + + return {"name": attr_name, "value": attr_value} + + func get_element_class_names(element: HTMLParser.HTMLElement) -> Array[String]: + var class_names: Array[String] = [] + var class_attr = element.get_attribute("class") + if class_attr.length() > 0: + var classes = class_attr.split(" ") + for cls in classes: + cls = cls.strip_edges() + if cls.length() > 0: + class_names.append(cls) + return class_names var stylesheet: CSSStylesheet var css_text: String diff --git a/Scripts/B9/HTMLParser.gd b/Scripts/B9/HTMLParser.gd index 4691011..fd20e9a 100644 --- a/Scripts/B9/HTMLParser.gd +++ b/Scripts/B9/HTMLParser.gd @@ -35,7 +35,10 @@ class HTMLElement: return text_content func get_bbcode_formatted_text(parser: HTMLParser = null) -> String: - return HTMLParser.get_bbcode_with_styles(self, {}, parser) # Pass empty dict for default + var styles = {} + if parser != null: + styles = parser.get_element_styles_with_inheritance(self, "", []) + return HTMLParser.get_bbcode_with_styles(self, styles, parser) func is_inline_element() -> bool: return tag_name in ["b", "i", "u", "small", "mark", "code", "span", "a", "input"] @@ -132,8 +135,8 @@ func get_element_styles_with_inheritance(element: HTMLElement, event: String = " var styles = {} - var class_names = extract_class_names_from_style(element) - styles.merge(parse_result.css_parser.stylesheet.get_styles_for_element(element.tag_name, event, class_names)) + var class_names = get_css_class_names(element) + styles.merge(parse_result.css_parser.stylesheet.get_styles_for_element(element.tag_name, event, class_names, element)) # Apply inline styles (higher priority) - force override CSS rules var inline_style = element.get_attribute("style") if inline_style.length() > 0: @@ -159,8 +162,8 @@ func get_element_styles_internal(element: HTMLElement, event: String = "") -> Di # Apply CSS rules if parse_result.css_parser: - var class_names = extract_class_names_from_style(element) - styles.merge(parse_result.css_parser.stylesheet.get_styles_for_element(element.tag_name, event, class_names)) + var class_names = get_css_class_names(element) + styles.merge(parse_result.css_parser.stylesheet.get_styles_for_element(element.tag_name, event, class_names, element)) # Apply inline styles (higher priority) - force override CSS rules var inline_style = element.get_attribute("style") @@ -199,6 +202,17 @@ func parse_inline_style_with_event(style_string: String, event: String = "") -> return properties +func get_css_class_names(element: HTMLElement) -> Array[String]: + var class_names: Array[String] = [] + var class_attr = element.get_attribute("class") + if class_attr.length() > 0: + var classes = class_attr.split(" ") + for cls in classes: + cls = cls.strip_edges() + if cls.length() > 0: + class_names.append(cls) + return class_names + func extract_class_names_from_style(element: HTMLElement) -> Array[String]: var class_names: Array[String] = [] var style_attr = element.get_attribute("style") @@ -291,7 +305,7 @@ func get_title() -> String: func get_icon() -> String: var icon_element = find_first("icon") - return icon_element.get_attribute("src") + return icon_element.get_attribute("src") if icon_element != null else "" func process_fonts() -> void: var font_elements = find_all("font") @@ -330,6 +344,48 @@ func apply_element_styles(node: Control, element: HTMLElement, parser: HTMLParse var text = HTMLParser.get_bbcode_with_styles(element, styles, parser) label.text = text +static func apply_element_bbcode_formatting(element: HTMLElement, styles: Dictionary, content: String) -> String: + match element.tag_name: + "b": + if styles.has("font-bold") and styles["font-bold"]: + return "[b]" + content + "[/b]" + "i": + if styles.has("font-italic") and styles["font-italic"]: + return "[i]" + content + "[/i]" + "u": + if styles.has("underline") and styles["underline"]: + return "[u]" + content + "[/u]" + "small": + if styles.has("font-size"): + return "[font_size=%d]%s[/font_size]" % [styles["font-size"], content] + else: + return "[font_size=20]%s[/font_size]" % content + "mark": + if styles.has("bg"): + var color = styles["bg"] + if typeof(color) == TYPE_COLOR: + color = color.to_html(false) + return "[bgcolor=#%s]%s[/bgcolor]" % [color, content] + else: + return "[bgcolor=#FFFF00]%s[/bgcolor]" % content + "code": + if styles.has("font-size"): + return "[font_size=%d][code]%s[/code][/font_size]" % [styles["font-size"], content] + else: + return "[font_size=20][code]%s[/code][/font_size]" % content + "a": + var href = element.get_attribute("href") + var color = "#1a0dab" + if styles.has("color"): + var c = styles["color"] + if typeof(c) == TYPE_COLOR: + color = "#" + c.to_html(false) + else: + color = str(c) + if href.length() > 0: + return "[color=%s][url=%s]%s[/url][/color]" % [color, href, content] + return content + static func get_bbcode_with_styles(element: HTMLElement, styles: Dictionary, parser: HTMLParser) -> String: var text = "" if element.text_content.length() > 0: @@ -340,46 +396,10 @@ static func get_bbcode_with_styles(element: HTMLElement, styles: Dictionary, par if parser != null: child_styles = parser.get_element_styles_with_inheritance(child, "", []) var child_content = HTMLParser.get_bbcode_with_styles(child, child_styles, parser) - match child.tag_name: - "b": - if child_styles.has("font-bold") and child_styles["font-bold"]: - child_content = "[b]" + child_content + "[/b]" - "i": - if child_styles.has("font-italic") and child_styles["font-italic"]: - child_content = "[i]" + child_content + "[/i]" - "u": - if child_styles.has("underline") and child_styles["underline"]: - child_content = "[u]" + child_content + "[/u]" - "small": - if child_styles.has("font-size"): - child_content = "[font_size=%d]%s[/font_size]" % [child_styles["font-size"], child_content] - else: - child_content = "[font_size=20]%s[/font_size]" % child_content - "mark": - if child_styles.has("bg"): - var color = child_styles["bg"] - if typeof(color) == TYPE_COLOR: - color = color.to_html(false) - child_content = "[bgcolor=#%s]%s[/bgcolor]" % [color, child_content] - else: - child_content = "[bgcolor=#FFFF00]%s[/bgcolor]" % child_content - "code": - if child_styles.has("font-size"): - child_content = "[font_size=%d][code]%s[/code][/font_size]" % [child_styles["font-size"], child_content] - else: - child_content = "[font_size=20][code]%s[/code][/font_size]" % child_content - "a": - var href = child.get_attribute("href") - var color = "#1a0dab" - if child_styles.has("color"): - var c = child_styles["color"] - if typeof(c) == TYPE_COLOR: - color = "#" + c.to_html(false) - else: - color = str(c) - if href.length() > 0: - child_content = "[color=%s][url=%s]%s[/url][/color]" % [color, href, child_content] - _: - pass + child_content = apply_element_bbcode_formatting(child, child_styles, child_content) text += child_content + + # Apply formatting to the current element itself + text = apply_element_bbcode_formatting(element, styles, text) + return text diff --git a/Scripts/Constants.gd b/Scripts/Constants.gd index 27d5f12..3799a17 100644 --- a/Scripts/Constants.gd +++ b/Scripts/Constants.gd @@ -99,7 +99,7 @@ var HTML_CONTENT2 = """ """.to_utf8_buffer() -var HTML_CONTENT = """ +var HTML_CONTENT4 = """ My cool web @@ -342,6 +342,84 @@ line breaks """.to_utf8_buffer() +var HTML_CONTENT = """ + CSS Selector Tests + + + + +

CSS Selector Test Page

+

This paragraph should be red and bold (h1 + p)

+

This paragraph should be blue (h1 ~ p)

+ +

Descendant vs Child Selectors

+
+

This paragraph should be purple and bold (div p and .outer-div > p)

+
+

This paragraph should be purple but not bold (div p only)

+
+
+ +

Attribute Selectors

+ + + + +
+ HTTP Link (normal) +
+ HTTPS Link (green and bold) + +
+ + + +

Sibling Selectors

+
This div should have light green bg (h2 + div)
+ This span should have light red bg (h3 ~ span) + This span should also have light red bg (h3 ~ span) + +
+ This span should have yellow bg (.container span) +

Regular paragraph in container

+
+ +
+ +
+ +
+
+ +
This div should have yellow bg (class ends with 'special')
+
This div should be normal
+ +""".to_utf8_buffer() + var HTML_CONTENT3 = """ Task Manager diff --git a/Scripts/StyleManager.gd b/Scripts/StyleManager.gd index e2b36fe..453a85b 100644 --- a/Scripts/StyleManager.gd +++ b/Scripts/StyleManager.gd @@ -32,6 +32,9 @@ static func apply_element_styles(node: Control, element: HTMLParser.HTMLElement, if button_node: target = button_node + if element.tag_name == "input": + apply_input_border_styles(node, styles) + # Unified font applying for label and button if target and styles.has("font-family") and styles["font-family"] not in ["sans-serif", "serif", "monospace"]: var main_node = Engine.get_main_loop().current_scene @@ -145,34 +148,34 @@ static func apply_styles_to_label(label: Control, styles: Dictionary, element: H # Apply font size if styles.has("font-size"): font_size = int(styles["font-size"]) + var has_existing_bbcode = text.contains("[url=") or text.contains("[color=") + # Apply color var color_tag = "" - if styles.has("color"): + if not has_existing_bbcode and styles.has("color"): var color = styles["color"] as Color - if color == Color.BLACK and StyleManager.body_text_color != Color.BLACK: color = StyleManager.body_text_color color_tag = "[color=#%s]" % color.to_html(false) - else: - if StyleManager.body_text_color != Color.BLACK: - color_tag = "[color=#%s]" % StyleManager.body_text_color.to_html(false) + elif not has_existing_bbcode and StyleManager.body_text_color != Color.BLACK: + color_tag = "[color=#%s]" % StyleManager.body_text_color.to_html(false) - # Apply bold + # Apply text styling (but not for text with existing BBCode) var bold_open = "" var bold_close = "" - if styles.has("font-bold") and styles["font-bold"]: + if not has_existing_bbcode and styles.has("font-bold") and styles["font-bold"]: bold_open = "[b]" bold_close = "[/b]" - # Apply italic + var italic_open = "" var italic_close = "" - if styles.has("font-italic") and styles["font-italic"]: + if not has_existing_bbcode and styles.has("font-italic") and styles["font-italic"]: italic_open = "[i]" italic_close = "[/i]" - # Apply underline + var underline_open = "" var underline_close = "" - if styles.has("underline") and styles["underline"]: + if not has_existing_bbcode and styles.has("underline") and styles["underline"]: underline_open = "[u]" underline_close = "[/u]" # Apply monospace font @@ -295,3 +298,45 @@ static func apply_font_to_button(button: Button, styles: Dictionary) -> void: if font_resource: button.add_theme_font_override("font", font_resource) + +static func apply_input_border_styles(input_node: Control, styles: Dictionary) -> void: + if not BackgroundUtils.needs_background_wrapper(styles): + return + + # Find the appropriate input control to style + var styleable_controls = [] + + # Get all potential input controls that support StyleBox + var line_edit = input_node.get_node_or_null("LineEdit") + var spinbox = input_node.get_node_or_null("SpinBox") + var file_container = input_node.get_node_or_null("FileContainer") + + if line_edit: styleable_controls.append(line_edit) + if spinbox: styleable_controls.append(spinbox) + if file_container: + var file_button = file_container.get_node_or_null("FileButton") + if file_button: styleable_controls.append(file_button) + + # Apply styles using BackgroundUtils + for control in styleable_controls: + var style_box = BackgroundUtils.create_stylebox_from_styles(styles) + + # Set appropriate content margins for inputs if not specified + if not styles.has("padding") and not styles.has("padding-left"): + style_box.content_margin_left = 5.0 + if not styles.has("padding") and not styles.has("padding-right"): + style_box.content_margin_right = 5.0 + if not styles.has("padding") and not styles.has("padding-top"): + style_box.content_margin_top = 2.0 + if not styles.has("padding") and not styles.has("padding-bottom"): + style_box.content_margin_bottom = 2.0 + + # Apply the style to the appropriate states + if control is LineEdit: + control.add_theme_stylebox_override("normal", style_box) + control.add_theme_stylebox_override("focus", style_box) + elif control is SpinBox: + control.add_theme_stylebox_override("normal", style_box) + control.add_theme_stylebox_override("focus", style_box) + elif control is Button: + control.add_theme_stylebox_override("normal", style_box) diff --git a/Scripts/main.gd b/Scripts/main.gd index 042d304..18c5554 100644 --- a/Scripts/main.gd +++ b/Scripts/main.gd @@ -34,6 +34,19 @@ const MIN_SIZE = Vector2i(750, 200) var font_dependent_elements: Array = [] +func should_group_as_inline(element: HTMLParser.HTMLElement) -> bool: + # Don't group inputs unless they're inside a form + if element.tag_name == "input": + # Check if this element has a form ancestor + var parent = element.parent + while parent: + if parent.tag_name == "form": + return true + parent = parent.parent + return false + + return element.is_inline_element() + func _ready(): ProjectSettings.set_setting("display/window/size/min_width", MIN_SIZE.x) ProjectSettings.set_setting("display/window/size/min_height", MIN_SIZE.y) @@ -79,11 +92,11 @@ func render() -> void: while i < body.children.size(): var element: HTMLParser.HTMLElement = body.children[i] - if element.is_inline_element(): + if should_group_as_inline(element): # Create an HBoxContainer for consecutive inline elements var inline_elements: Array[HTMLParser.HTMLElement] = [] - while i < body.children.size() and body.children[i].is_inline_element(): + while i < body.children.size() and should_group_as_inline(body.children[i]): inline_elements.append(body.children[i]) i += 1 @@ -95,8 +108,8 @@ func render() -> void: if inline_node: safe_add_child(hbox, inline_node) # Handle hyperlinks for all inline elements - if contains_hyperlink(inline_element) and inline_node.rich_text_label: - inline_node.rich_text_label.meta_clicked.connect(func(meta): OS.shell_open(str(meta))) + if contains_hyperlink(inline_element) and inline_node is RichTextLabel: + inline_node.meta_clicked.connect(func(meta): OS.shell_open(str(meta))) else: print("Failed to create inline element node: ", inline_element.tag_name) @@ -110,8 +123,11 @@ func render() -> void: safe_add_child(website_container, element_node) # Handle hyperlinks for all elements - if contains_hyperlink(element) and element_node.has_method("get") and element_node.get("rich_text_label"): - element_node.rich_text_label.meta_clicked.connect(func(meta): OS.shell_open(str(meta))) + if contains_hyperlink(element): + if element_node is RichTextLabel: + element_node.meta_clicked.connect(func(meta): OS.shell_open(str(meta))) + elif element_node.has_method("get") and element_node.get("rich_text_label"): + element_node.rich_text_label.meta_clicked.connect(func(meta): OS.shell_open(str(meta))) else: print("Couldn't parse unsupported HTML tag \"%s\"" % element.tag_name)