diff --git a/.github/workflows/flumi-cross-compilation.yml b/.github/workflows/flumi-cross-compilation.yml new file mode 100644 index 0000000..4ca05b5 --- /dev/null +++ b/.github/workflows/flumi-cross-compilation.yml @@ -0,0 +1,133 @@ +name: Build Flumi + +on: + push: + branches: ["**"] + workflow_dispatch: + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + env: + GODOT_VERSION: "4.4" + EXPORT_DIR: "build" + LINUX_NAME: "Flumi-Linux-x86_64" + WINDOWS_NAME: "Flumi-Windows-x86_64" + + steps: + - name: Checkout (with LFS) + uses: actions/checkout@v4 + with: + lfs: true + + - name: Install Godot (headless) and templates + run: | + set -euxo pipefail + mkdir -p tools/godot + cd tools/godot + curl -L -o godot.zip "https://github.com/godotengine/godot-builds/releases/download/4.4-stable/Godot_v4.4-stable_linux.x86_64.zip" + unzip -q godot.zip + mv Godot_v4.4-stable_linux.x86_64 Godot + chmod +x Godot + cd - + TEMPLATES_DIR="$HOME/.local/share/godot/export_templates/4.4.stable" + mkdir -p "${TEMPLATES_DIR}" + curl -L -o templates.tpz "https://github.com/godotengine/godot-builds/releases/download/4.4-stable/Godot_v4.4-stable_export_templates.tpz" + unzip -q templates.tpz -d templates_unpacked + cp -R templates_unpacked/templates/* "${TEMPLATES_DIR}/" + + - name: Export Linux build + run: | + set -euxo pipefail + mkdir -p build + cd flumi + ../tools/godot/Godot --headless --path . --export-release "Linux" "../build/${LINUX_NAME}.x86_64" + + - name: Export Windows build + run: | + set -euxo pipefail + mkdir -p build/Windows + cd flumi + ../tools/godot/Godot --headless --path . --export-release "Windows Desktop" "../build/Windows/${WINDOWS_NAME}.exe" + + - name: Create Linux archive + run: | + set -euxo pipefail + cd build + tar -czf "${LINUX_NAME}.tar.gz" "${LINUX_NAME}.x86_64" "${LINUX_NAME}.pck" *.so 2>/dev/null || true + + - name: Download UPX + run: | + set -euxo pipefail + mkdir -p tools/upx + cd tools/upx + curl -L -o upx.tar.xz "https://github.com/upx/upx/releases/download/v5.0.2/upx-5.0.2-amd64_linux.tar.xz" + tar -xf upx.tar.xz + cd - + + - name: Compress Windows executables with UPX + run: | + set -euxo pipefail + cd build/Windows + ls -la + # Compress all exe and dll files that exist + for file in *.exe *.dll; do + if [ -f "$file" ]; then + echo "Compressing $file" + ../../tools/upx/upx-5.0.2-amd64_linux/upx --best --ultra-brute "$file" + fi + done + + - name: Create custom InnoSetup Dockerfile + run: | + set -euxo pipefail + cat > Dockerfile.innosetup << 'EOF' + FROM docker.io/amake/innosetup + USER root + ENV HOME /home/xclient + ENV WINEPREFIX /home/xclient/.wine + ENV WINEARCH win32 + RUN chown -R root /home + WORKDIR /work + ENTRYPOINT ["iscc"] + EOF + + - name: Build custom InnoSetup Docker image + run: | + set -euxo pipefail + docker build -f Dockerfile.innosetup -t flumi-innosetup . + + - name: Build Windows installer + run: | + set -euxo pipefail + mkdir -p build/Windows/installer + + docker run --rm \ + -v "$PWD:/work" \ + -w /work/flumi/build-scripts \ + flumi-innosetup flumi-workflow-installer.iss + + - name: Create Windows installer archive + run: | + set -euxo pipefail + cd build/Windows/installer + if [ -f "Flumi-Setup-Latest.exe" ]; then + zip -j "../../Flumi-Windows-x86_64.zip" "Flumi-Setup-Latest.exe" + else + echo "Installer not found!" + fi + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: builds-${{ github.sha }} + path: | + build/Flumi-Linux-x86_64.tar.gz + build/Flumi-Windows-x86_64.zip + if-no-files-found: warn + retention-days: 14 \ No newline at end of file diff --git a/flumi/Scripts/B9/Lua.gd b/flumi/Scripts/B9/Lua.gd index 405a7c7..9f9e7e1 100644 --- a/flumi/Scripts/B9/Lua.gd +++ b/flumi/Scripts/B9/Lua.gd @@ -17,6 +17,7 @@ class EventSubscription: var wrapper_func: Callable var dom_parser: HTMLParser +var associated_tab: Tab = null var event_subscriptions: Dictionary = {} var next_subscription_id: int = 1 var next_callback_ref: int = 1 @@ -81,7 +82,6 @@ func _gurt_select_all_handler(vm: LuauVM) -> int: for element in elements: var element_id = get_or_assign_element_id(element) - # Create element wrapper vm.lua_newtable() vm.lua_pushstring(element_id) vm.lua_setfield(-2, "_element_id") @@ -90,7 +90,6 @@ func _gurt_select_all_handler(vm: LuauVM) -> int: LuaDOMUtils.add_element_methods(vm, self) - # Add to array at index vm.lua_rawseti(-2, index) index += 1 @@ -106,20 +105,16 @@ func _gurt_create_handler(vm: LuauVM) -> int: var element = HTMLParser.HTMLElement.new(tag_name) - # Apply options as attributes and content for key in options: if key == "text": element.text_content = str(options[key]) else: element.attributes[str(key)] = str(options[key]) - # Add to parser's element collection first dom_parser.parse_result.all_elements.append(element) - # Get or assign stable ID var unique_id = get_or_assign_element_id(element) - # Create Lua element wrapper with methods vm.lua_newtable() vm.lua_pushstring(unique_id) vm.lua_setfield(-2, "_element_id") @@ -263,14 +258,11 @@ func _element_on_event_handler(vm: LuauVM) -> int: var element_id: String = vm.lua_tostring(-1) vm.lua_pop(1) - # Create a proper subscription with real ID var subscription = _create_subscription(vm, element_id, event_name) event_subscriptions[subscription.id] = subscription - # Register the event on main thread call_deferred("_register_event_on_main_thread", element_id, event_name, subscription.callback_ref, subscription.id) - # Return subscription with proper unsubscribe method vm.lua_newtable() vm.lua_pushinteger(subscription.id) vm.lua_setfield(-2, "_subscription_id") @@ -347,13 +339,13 @@ func _handle_subscription_result(vm: LuauVM, subscription: EventSubscription, su # Event callbacks func _on_event_triggered(subscription: EventSubscription) -> void: - if not event_subscriptions.has(subscription.id): + if not _is_subscription_valid(subscription): return _execute_lua_callback(subscription) func _on_gui_input_click(event: InputEvent, subscription: EventSubscription) -> void: - if not event_subscriptions.has(subscription.id): + if not _is_subscription_valid(subscription): return if event is InputEventMouseButton: @@ -366,7 +358,6 @@ func _on_gui_input_mouse_universal(event: InputEvent, signal_node: Node) -> void if event is InputEventMouseButton: var mouse_event = event as InputEventMouseButton if mouse_event.button_index == MOUSE_BUTTON_LEFT: - # Find all subscriptions for this node with mouse events for subscription_id in event_subscriptions: var subscription = event_subscriptions[subscription_id] if subscription.connected_node == signal_node and subscription.connected_signal == "gui_input_mouse": @@ -409,7 +400,7 @@ func _on_gui_input_keys_universal(event: InputEvent, signal_node: Node) -> void: # Event callback handlers func _on_gui_input_mousemove(event: InputEvent, subscription: EventSubscription) -> void: - if not event_subscriptions.has(subscription.id): + if not _is_subscription_valid(subscription): return if event is InputEventMouseMotion: @@ -417,7 +408,7 @@ func _on_gui_input_mousemove(event: InputEvent, subscription: EventSubscription) _handle_mousemove_event(mouse_event, subscription) func _on_focus_gui_input(event: InputEvent, subscription: EventSubscription) -> void: - if not event_subscriptions.has(subscription.id): + if not _is_subscription_valid(subscription): return if event is InputEventMouseButton: @@ -436,16 +427,22 @@ func _on_body_mouse_enter(subscription: EventSubscription) -> void: func _on_body_mouse_exit(subscription: EventSubscription) -> void: _handle_body_event(subscription, "mouseexit", {}) +func _is_subscription_valid(subscription: EventSubscription) -> bool: + return event_subscriptions.has(subscription.id) + func _execute_lua_callback(subscription: EventSubscription, args: Array = []) -> void: threaded_vm.execute_callback_async(subscription.callback_ref, args) func _execute_input_event_callback(subscription: EventSubscription, event_data: Dictionary) -> void: - if not event_subscriptions.has(subscription.id): + if not _is_subscription_valid(subscription): return _execute_lua_callback(subscription, [event_data]) # Global input processing func _input(event: InputEvent) -> void: + if not _is_tab_active(): + return + if event is InputEventKey: var key_event = event as InputEventKey for subscription_id in event_subscriptions: @@ -546,11 +543,25 @@ func _handle_mousemove_event(mouse_event: InputEventMouseMotion, subscription: E } _execute_lua_callback(subscription, [mouse_info]) +func _is_tab_active() -> bool: + if not associated_tab: + return true + + var main_scene = Engine.get_main_loop().current_scene + if main_scene and main_scene.has_method("get_active_tab"): + return main_scene.get_active_tab() == associated_tab + + return true + +func _execute_body_event_callbacks(event_name: String, event_data: Dictionary = {}) -> void: + for subscription_id in event_subscriptions: + var subscription = event_subscriptions[subscription_id] + if subscription.element_id == "body" and subscription.event_name == event_name: + _execute_lua_callback(subscription, [event_data]) + func _get_body_container() -> Control: - # Try to get body from DOM registry first var body_container = dom_parser.parse_result.dom_nodes.get("body", null) - # We fallback to finding the active website container, as it seems theres a bug where body can be null in this context if not body_container: var main_scene = Engine.get_main_loop().current_scene if main_scene and main_scene.has_method("get_active_website_container"): @@ -573,7 +584,6 @@ func _on_input_focus_lost(subscription: EventSubscription) -> void: if not event_subscriptions.has(subscription.id): return - # Get the current text value from the input node var dom_node = dom_parser.parse_result.dom_nodes.get(subscription.element_id, null) if dom_node: var current_text = "" @@ -602,7 +612,6 @@ func _on_input_item_selected(index: int, subscription: EventSubscription) -> voi if not event_subscriptions.has(subscription.id): return - # Get value from OptionButton var dom_node = dom_parser.parse_result.dom_nodes.get(subscription.element_id, null) var value = "" var text = "" @@ -610,7 +619,6 @@ func _on_input_item_selected(index: int, subscription: EventSubscription) -> voi if dom_node and dom_node is OptionButton: var option_button = dom_node as OptionButton text = option_button.get_item_text(index) - # Get actual value attribute (stored as metadata) var metadata = option_button.get_item_metadata(index) value = str(metadata) if metadata != null else text @@ -624,16 +632,15 @@ func _on_file_selected(file_path: String, subscription: EventSubscription) -> vo var dom_node = dom_parser.parse_result.dom_nodes.get(subscription.element_id, null) if dom_node: - var file_container = dom_node.get_parent() # FileContainer (HBoxContainer) + var file_container = dom_node.get_parent() if file_container: - var input_element = file_container.get_parent() # Input Control + var input_element = file_container.get_parent() if input_element and input_element.has_method("get_file_info"): var file_info = input_element.get_file_info() if not file_info.is_empty(): _execute_lua_callback(subscription, [file_info]) return - # Fallback var file_name = file_path.get_file() _execute_lua_callback(subscription, [{"fileName": file_name}]) @@ -648,7 +655,6 @@ func _on_form_submit(subscription: EventSubscription) -> void: if not event_subscriptions.has(subscription.id): return - # Find parent form var form_data = {} var element = dom_parser.find_by_id(subscription.element_id) if element: @@ -728,7 +734,6 @@ func get_dom_node(node: Node, purpose: String = "general") -> Node: # Main execution function func execute_lua_script(code: String, chunk_name: String = "dostring"): if not threaded_vm.lua_thread or not threaded_vm.lua_thread.is_alive(): - # Start the thread if it's not running threaded_vm.start_lua_thread(dom_parser, self) script_start_time = Time.get_ticks_msec() / 1000.0 @@ -752,7 +757,6 @@ func _on_print_output(message: Dictionary): func kill_script_execution(): threaded_vm.stop_lua_thread() - # Restart a fresh thread for future scripts threaded_vm.start_lua_thread(dom_parser, self) func is_script_hanging() -> bool: @@ -819,7 +823,6 @@ func _handle_dom_operation(operation: Dictionary): LuaCanvasUtils.handle_canvas_stroke(operation, dom_parser) "canvas_fill": LuaCanvasUtils.handle_canvas_fill(operation, dom_parser) - # Transformation operations "canvas_save": LuaCanvasUtils.handle_canvas_save(operation, dom_parser) "canvas_restore": @@ -834,7 +837,6 @@ func _handle_dom_operation(operation: Dictionary): LuaCanvasUtils.handle_canvas_quadraticCurveTo(operation, dom_parser) "canvas_bezierCurveTo": LuaCanvasUtils.handle_canvas_bezierCurveTo(operation, dom_parser) - # Style property operations "canvas_setStrokeStyle": LuaCanvasUtils.handle_canvas_setStrokeStyle(operation, dom_parser) "canvas_setFillStyle": @@ -859,7 +861,6 @@ func _handle_event_registration(operation: Dictionary): var element_id = get_or_assign_element_id(element) - # Create subscription for threaded callback var subscription = EventSubscription.new() subscription.id = next_subscription_id next_subscription_id += 1 @@ -871,7 +872,6 @@ func _handle_event_registration(operation: Dictionary): event_subscriptions[subscription.id] = subscription - # Connect to DOM element var dom_node = dom_parser.parse_result.dom_nodes.get(element_id, null) if dom_node: var signal_node = get_dom_node(dom_node, "signal") @@ -883,7 +883,6 @@ func _handle_text_setting(operation: Dictionary): var element = SelectorUtils.find_first_matching(selector, dom_parser.parse_result.all_elements) if element: - # If the element has a DOM node, update it directly without updating text_content var element_id = get_or_assign_element_id(element) var dom_node = dom_parser.parse_result.dom_nodes.get(element_id, null) @@ -999,12 +998,10 @@ func _handle_body_event_registration(operation: Dictionary): var callback_ref: int = operation.callback_ref var subscription_id: int = operation.get("subscription_id", -1) - # Use provided subscription_id or generate a new one if subscription_id == -1: subscription_id = next_subscription_id next_subscription_id += 1 - # Create subscription for threaded callback var subscription = EventSubscription.new() subscription.id = subscription_id subscription.element_id = "body" @@ -1019,7 +1016,6 @@ func _handle_body_event_registration(operation: Dictionary): LuaEventUtils.connect_body_event(event_name, subscription, self) func _register_event_on_main_thread(element_id: String, event_name: String, callback_ref: int, subscription_id: int = -1): - # This runs on the main thread - safe to access DOM nodes var dom_node = dom_parser.parse_result.dom_nodes.get(element_id, null) if not dom_node: var pending_registration = { @@ -1037,12 +1033,10 @@ func _register_event_on_main_thread(element_id: String, event_name: String, call call_deferred("_process_pending_event_registrations") return - # Use provided subscription_id or generate a new one if subscription_id == -1: subscription_id = next_subscription_id next_subscription_id += 1 - # Create subscription using the threaded VM's callback reference var subscription = EventSubscription.new() subscription.id = subscription_id subscription.element_id = element_id @@ -1080,13 +1074,11 @@ func _process_pending_event_registrations(): call_deferred("_process_pending_event_registrations") func _unsubscribe_event_on_main_thread(subscription_id: int): - # This runs on the main thread - safe to cleanup event subscriptions var subscription = event_subscriptions.get(subscription_id, null) if subscription: LuaEventUtils.disconnect_subscription(subscription, self) event_subscriptions.erase(subscription_id) - # Clean up Lua callback reference if subscription.callback_ref and subscription.vm: subscription.vm.lua_pushnil() subscription.vm.lua_rawseti(subscription.vm.LUA_REGISTRYINDEX, subscription.callback_ref) @@ -1112,7 +1104,6 @@ func _get_element_size_sync(result: Array, element_id: String): result[2] = true # completion flag return - # Fallback result[0] = 0.0 result[1] = 0.0 result[2] = true # completion flag @@ -1126,7 +1117,6 @@ func _get_element_position_sync(result: Array, element_id: String): result[2] = true # completion flag return - # Fallback result[0] = 0.0 result[1] = 0.0 result[2] = true # completion flag diff --git a/flumi/Scripts/Browser/TabContainer.gd b/flumi/Scripts/Browser/TabContainer.gd index 771d3ec..1f28d6f 100644 --- a/flumi/Scripts/Browser/TabContainer.gd +++ b/flumi/Scripts/Browser/TabContainer.gd @@ -64,16 +64,13 @@ func _tab_closed(index: int) -> void: if index <= active_tab: if index == active_tab: - # Closed tab was active, select right neighbor (or last tab if at end) if index >= tabs.size(): active_tab = tabs.size() - 1 else: active_tab = index else: - # Closed tab was before active tab, shift active index down active_tab -= 1 - # Reconnect signals with updated indices for i in tabs.size(): tabs[i].tab_pressed.disconnect(_tab_pressed) tabs[i].tab_closed.disconnect(_tab_closed) @@ -172,7 +169,11 @@ func set_active_tab(index: int) -> void: if index < 0 or index >= tabs.size(): return + var old_tab_index = active_tab + if active_tab >= 0 and active_tab < tabs.size(): + _trigger_tab_focusout(tabs[active_tab]) + tabs[active_tab].is_active = false tabs[active_tab].button.add_theme_stylebox_override("normal", TAB_DEFAULT) tabs[active_tab].button.add_theme_stylebox_override("pressed", TAB_DEFAULT) @@ -188,6 +189,10 @@ func set_active_tab(index: int) -> void: tabs[index].gradient_texture.texture = TAB_GRADIENT tabs[index].show_content() + if old_tab_index != index: + _trigger_tab_focusin(tabs[index]) + call_deferred("_fix_tab_layout", tabs[index]) + if not tabs[index].website_container: if main: trigger_init_scene(tabs[index]) @@ -254,3 +259,57 @@ func _input(_event: InputEvent) -> void: func _on_new_tab_button_pressed() -> void: create_tab() + +func _trigger_tab_focusout(tab: Tab) -> void: + if not tab or tab.lua_apis.is_empty(): + return + + for lua_api in tab.lua_apis: + if is_instance_valid(lua_api): + lua_api._execute_body_event_callbacks("focusout") + +func _trigger_tab_focusin(tab: Tab) -> void: + if not tab or tab.lua_apis.is_empty(): + return + + for lua_api in tab.lua_apis: + if is_instance_valid(lua_api): + lua_api._execute_body_event_callbacks("focusin") + +func _fix_tab_layout(tab: Tab) -> void: + if not tab or not tab.website_container: + return + + if tab.website_container.has_meta("stored_layout_valid"): + tab.website_container.remove_meta("stored_layout_valid") + _fix_container_layout_recursive(tab.website_container) + await get_tree().process_frame + else: + tab.website_container.call_deferred("queue_redraw") + if tab.background_panel: + tab.background_panel.call_deferred("queue_redraw") + + _fix_container_layout_recursive(tab.website_container) + +func _fix_container_layout_recursive(container: Control) -> void: + if not container: + return + + if container is FlexContainer: + container.queue_redraw() + container.update_minimum_size() + if container.has_method("queue_sort"): + container.queue_sort() + if container.has_method("_notification"): + container._notification(NOTIFICATION_RESIZED) + elif container is VBoxContainer or container is HBoxContainer or container is GridContainer: + container.queue_redraw() + container.update_minimum_size() + container.queue_sort() + elif container is MarginContainer: + container.queue_redraw() + container.update_minimum_size() + + for child in container.get_children(): + if child is Control: + _fix_container_layout_recursive(child) diff --git a/flumi/Scripts/Engine/AutoSizingFlexContainer.gd b/flumi/Scripts/Engine/AutoSizingFlexContainer.gd index 493a32d..956c4fe 100644 --- a/flumi/Scripts/Engine/AutoSizingFlexContainer.gd +++ b/flumi/Scripts/Engine/AutoSizingFlexContainer.gd @@ -43,8 +43,8 @@ func _resort() -> void: var target_index = _find_index_from_flex_list(_flex_list, cid) var flexbox: Flexbox - # If the child is not visible, remove its corresponding flexbox node - if not c.is_visible_in_tree(): + # If the child shouldn't participate in layout, remove its corresponding flexbox node + if not _should_include_child_in_layout(c): if target_index != -1: _root.remove_child_at(target_index) _flex_list.remove_at(target_index) @@ -164,6 +164,15 @@ func _resort() -> void: emit_signal("flex_resized") +func _should_include_child_in_layout(c: Control) -> bool: + if not is_instance_valid(c): + return false + + if not c.visible or not c.is_inside_tree(): + return false + + return true + func _is_inside_background_container() -> bool: var current_parent = get_parent() while current_parent: diff --git a/flumi/Scripts/Tags/p.gd b/flumi/Scripts/Tags/p.gd index 0b49c6d..a0d9f31 100644 --- a/flumi/Scripts/Tags/p.gd +++ b/flumi/Scripts/Tags/p.gd @@ -5,6 +5,7 @@ var _element: HTMLParser.HTMLElement var _parser: HTMLParser const BROWSER_THEME = preload("res://Scenes/Styles/BrowserText.tres") +const AUTO_RESIZE_VISIBILITY_META := "auto_resize_visibility_watch" func init(element, parser: HTMLParser) -> void: _element = element @@ -85,6 +86,15 @@ func _apply_auto_resize_to_label(label: RichTextLabel): if not is_instance_valid(label) or not is_instance_valid(self): return + + if not label.is_visible_in_tree(): + if not label.has_meta(AUTO_RESIZE_VISIBILITY_META): + label.set_meta(AUTO_RESIZE_VISIBILITY_META, true) + label.visibility_changed.connect(_on_label_visibility_changed.bind(label), Object.CONNECT_ONE_SHOT) + return + + if label.has_meta(AUTO_RESIZE_VISIBILITY_META): + label.remove_meta(AUTO_RESIZE_VISIBILITY_META) var min_width = 20 var max_width = 800 @@ -99,10 +109,14 @@ func _apply_auto_resize_to_label(label: RichTextLabel): if not is_instance_valid(label) or not is_instance_valid(self): return - var natural_width = label.size.x - natural_width *= 1.0 + var content_width = 0.0 + if label.has_method("get_content_width"): + content_width = label.get_content_width() + var natural_width = max(content_width, label.size.x) + if natural_width <= 0.0: + natural_width = label.get_minimum_size().x - var desired_width = clampf(natural_width, min_width, max_width) + var desired_width = clamp(natural_width, min_width, max_width) label.autowrap_mode = original_autowrap @@ -115,6 +129,15 @@ func _apply_auto_resize_to_label(label: RichTextLabel): label.queue_redraw() +func _on_label_visibility_changed(label: RichTextLabel) -> void: + if not is_instance_valid(self) or not is_instance_valid(label): + return + + if label.is_visible_in_tree(): + call_deferred("_apply_auto_resize_to_label", label) + else: + label.visibility_changed.connect(_on_label_visibility_changed.bind(label), Object.CONNECT_ONE_SHOT) + func contains_hyperlink(element: HTMLParser.HTMLElement) -> bool: if element.tag_name == "a": return true diff --git a/flumi/Scripts/Tags/textarea.gd b/flumi/Scripts/Tags/textarea.gd index fe5bca0..cc03643 100644 --- a/flumi/Scripts/Tags/textarea.gd +++ b/flumi/Scripts/Tags/textarea.gd @@ -76,9 +76,19 @@ func init(element: HTMLParser.HTMLElement, parser: HTMLParser) -> void: func _on_text_changed(max_length: int) -> void: var text_edit = $TextEdit as TextEdit + if not text_edit: + return + if text_edit.text.length() > max_length: var cursor_pos = text_edit.get_caret_column() var line_pos = text_edit.get_caret_line() text_edit.text = text_edit.text.substr(0, max_length) + var line_count = text_edit.get_line_count() + if line_count == 0: + text_edit.set_caret_line(0) + text_edit.set_caret_column(0) + return + line_pos = clamp(line_pos, 0, line_count - 1) text_edit.set_caret_line(line_pos) - text_edit.set_caret_column(min(cursor_pos, text_edit.get_line(line_pos).length())) + var line_text = text_edit.get_line(line_pos) + text_edit.set_caret_column(min(cursor_pos, line_text.length())) diff --git a/flumi/Scripts/main.gd b/flumi/Scripts/main.gd index 79da534..0580a20 100644 --- a/flumi/Scripts/main.gd +++ b/flumi/Scripts/main.gd @@ -40,6 +40,7 @@ const CANVAS = preload("res://Scenes/Tags/canvas.tscn") const DOWNLOAD_MANAGER = preload("res://Scripts/Browser/DownloadManager.gd") const MIN_SIZE = Vector2i(750, 200) +const RENDER_YIELD_BATCH := 40 var font_dependent_elements: Array = [] var current_domain = "" @@ -47,6 +48,7 @@ var main_navigation_request: NetworkRequest = null var network_start_time: float = 0.0 var network_end_time: float = 0.0 var download_manager: DownloadManager = null +var _render_batch_counter: int = 0 func should_group_as_inline(element: HTMLParser.HTMLElement) -> bool: if element.tag_name == "input": @@ -140,17 +142,42 @@ func fetch_gurt_content_async(gurt_url: String, tab: Tab, original_url: String, main_navigation_request.type = NetworkRequest.RequestType.DOC network_start_time = Time.get_ticks_msec() - var thread = Thread.new() - var request_data = {"gurt_url": gurt_url} + var http_request = HTTPRequest.new() + add_child(http_request) - thread.start(_perform_gurt_request_threaded.bind(request_data)) + var request_info = { + "tab": tab, + "original_url": original_url, + "gurt_url": gurt_url, + "add_to_history": add_to_history, + "http_request": http_request + } - while thread.is_alive(): - await get_tree().process_frame + http_request.request_completed.connect(_on_gurt_request_completed.bind(request_info)) - var result = thread.wait_to_finish() + _start_async_gurt_request(http_request, gurt_url) + +func _start_async_gurt_request(http_request: HTTPRequest, gurt_url: String) -> void: + var thread = Thread.new() + var request_data = {"gurt_url": gurt_url, "http_request": http_request} + + thread.start(_perform_gurt_request_threaded.bind(request_data)) - _handle_gurt_result(result, tab, original_url, gurt_url, add_to_history) + var timer = Timer.new() + timer.wait_time = 0.016 # ~60 FPS check rate + timer.timeout.connect(_check_thread_completion.bind(thread, http_request)) + add_child(timer) + timer.start() + +func _check_thread_completion(thread: Thread, http_request: HTTPRequest) -> void: + if not thread.is_alive(): + var result = thread.wait_to_finish() + http_request.request_completed.emit(200 if result.success else 400, 200 if result.success else 400, PackedStringArray(), result.get("html_bytes", PackedByteArray())) + + for child in get_children(): + if child is Timer and child.timeout.is_connected(_check_thread_completion): + child.queue_free() + break func _perform_gurt_request_threaded(request_data: Dictionary) -> Dictionary: var gurt_url: String = request_data.gurt_url @@ -175,6 +202,23 @@ func _perform_gurt_request_threaded(request_data: Dictionary) -> Dictionary: return {"success": true, "html_bytes": response.body} +func _on_gurt_request_completed(result_code: int, response_code: int, headers: PackedStringArray, body: PackedByteArray, request_info: Dictionary) -> void: + var http_request = request_info.http_request + var tab = request_info.tab + var original_url = request_info.original_url + var gurt_url = request_info.gurt_url + var add_to_history = request_info.add_to_history + + if is_instance_valid(http_request): + http_request.queue_free() + + if result_code == 200: + var result = {"success": true, "html_bytes": body} + _handle_gurt_result(result, tab, original_url, gurt_url, add_to_history) + else: + var result = {"success": false, "error": "Request failed with code: " + str(result_code)} + _handle_gurt_result(result, tab, original_url, gurt_url, add_to_history) + func fetch_local_file_content_async(file_url: String, tab: Tab, original_url: String, add_to_history: bool = true) -> void: var file_path = URLUtils.file_url_to_path(file_url) @@ -221,7 +265,7 @@ func handle_local_file_result(result: Dictionary, tab: Tab, original_url: String if not search_bar.has_focus(): search_bar.text = original_url - render_content(html_bytes) + render_content(html_bytes, tab) tab.stop_loading() @@ -233,7 +277,7 @@ func handle_local_file_result(result: Dictionary, tab: Tab, original_url: String func handle_local_file_error(error_message: String, tab: Tab) -> void: var error_html = FileUtils.create_error_page("File Access Error", error_message) - render_content(error_html) + render_content(error_html, tab) const FOLDER_ICON = preload("res://Assets/Icons/folder.svg") tab.stop_loading() @@ -256,7 +300,7 @@ func _handle_gurt_result(result: Dictionary, tab: Tab, original_url: String, gur if not search_bar.has_focus(): search_bar.text = original_url # Show the original input in search bar - render_content(html_bytes) + render_content(html_bytes, tab) if main_navigation_request: main_navigation_request.end_time = network_end_time @@ -276,7 +320,7 @@ func _handle_gurt_result(result: Dictionary, tab: Tab, original_url: String, gur func handle_gurt_error(error_message: String, tab: Tab) -> void: var error_html = GurtProtocol.create_error_page(error_message) - render_content(error_html) + render_content(error_html, tab) const GLOBE_ICON = preload("res://Assets/Icons/globe.svg") tab.stop_loading() @@ -298,32 +342,45 @@ func _on_search_focus_exited() -> void: func render() -> void: render_content(Constants.HTML_CONTENT) -func render_content(html_bytes: PackedByteArray) -> void: +func render_content(html_bytes: PackedByteArray, target_tab: Tab = null) -> void: if main_navigation_request: NetworkManager.clear_all_requests_except(main_navigation_request.id) else: NetworkManager.clear_all_requests() - var active_tab = get_active_tab() + var rendering_tab = target_tab if target_tab else get_active_tab() var target_container: Control - if active_tab and active_tab.website_container: - target_container = active_tab.website_container + if rendering_tab and rendering_tab.website_container: + target_container = rendering_tab.website_container else: target_container = website_container if not target_container: print("Error: No container available for rendering") return + + _reset_render_yield_state() - if active_tab: - var existing_tab_lua_apis = active_tab.lua_apis + var was_tab_visible = false + var active_tab = get_active_tab() + var is_rendering_active_tab = (rendering_tab == active_tab) + var needs_visibility_restore = false + + if rendering_tab and not is_rendering_active_tab and rendering_tab.background_panel: + was_tab_visible = rendering_tab.background_panel.visible + if not was_tab_visible: + _copy_active_container_sizes_to_tab(rendering_tab) + needs_visibility_restore = true + + if rendering_tab: + var existing_tab_lua_apis = rendering_tab.lua_apis for lua_api in existing_tab_lua_apis: if is_instance_valid(lua_api): lua_api.kill_script_execution() remove_child(lua_api) lua_api.queue_free() - active_tab.lua_apis.clear() + rendering_tab.lua_apis.clear() var existing_postprocess = [] for child in get_children(): @@ -334,8 +391,8 @@ func render_content(html_bytes: PackedByteArray) -> void: remove_child(postprocess) postprocess.queue_free() - if active_tab.background_panel: - var existing_overlay = active_tab.background_panel.get_node_or_null("PostprocessOverlay") + if rendering_tab.background_panel: + var existing_overlay = rendering_tab.background_panel.get_node_or_null("PostprocessOverlay") if existing_overlay: existing_overlay.queue_free() else: @@ -363,13 +420,13 @@ func render_content(html_bytes: PackedByteArray) -> void: if existing_overlay: existing_overlay.queue_free() - if target_container.get_parent() and target_container.get_parent().name == "BodyMarginContainer": - var body_margin_container = target_container.get_parent() - var scroll_container = body_margin_container.get_parent() - if scroll_container: - body_margin_container.remove_child(target_container) - scroll_container.remove_child(body_margin_container) - body_margin_container.queue_free() + var target_parent = target_container.get_parent() + if target_parent and target_parent.name == "BodyMarginContainer": + var scroll_container = target_parent.get_parent() + if scroll_container and target_container.get_parent() == target_parent and target_parent.get_parent() == scroll_container: + target_parent.remove_child(target_container) + scroll_container.remove_child(target_parent) + target_parent.queue_free() scroll_container.add_child(target_container) for child in target_container.get_children(): @@ -381,20 +438,24 @@ func render_content(html_bytes: PackedByteArray) -> void: var parser: HTMLParser = HTMLParser.new(html_bytes) var parse_result = parser.parse() + await _yield_for_ui() parser.process_styles() + await _yield_for_ui() if parse_result.external_css and not parse_result.external_css.is_empty(): await parser.process_external_styles(current_domain) + await _yield_for_ui() # Process and load all custom fonts defined in tags parser.process_fonts(current_domain) FontManager.load_all_fonts() + await _yield_for_ui() if parse_result.errors.size() > 0: print("Parse errors: " + str(parse_result.errors)) - var tab = active_tab + var tab = rendering_tab var title = parser.get_title() tab.set_title(title) @@ -408,7 +469,10 @@ func render_content(html_bytes: PackedByteArray) -> void: var body = parser.find_first("body") if body: - var background_panel = active_tab.background_panel + var background_panel = rendering_tab.background_panel + + if needs_visibility_restore: + target_container.call_deferred("queue_redraw") StyleManager.apply_body_styles(body, parser, target_container, background_panel) @@ -418,8 +482,9 @@ func render_content(html_bytes: PackedByteArray) -> void: var lua_api = LuaAPI.new() add_child(lua_api) - if active_tab: - active_tab.lua_apis.append(lua_api) + if rendering_tab: + rendering_tab.lua_apis.append(lua_api) + lua_api.associated_tab = rendering_tab lua_api.dom_parser = parser @@ -451,6 +516,7 @@ func render_content(html_bytes: PackedByteArray) -> void: parser.register_dom_node(inline_element, inline_node) safe_add_child(hbox, inline_node) + await _maybe_yield_render() # Handle hyperlinks for all inline elements if contains_hyperlink(inline_element) and inline_node is RichTextLabel: inline_node.meta_clicked.connect(handle_link_click) @@ -458,6 +524,7 @@ func render_content(html_bytes: PackedByteArray) -> void: print("Failed to create inline element node: ", inline_element.tag_name) safe_add_child(target_container, hbox) + await _maybe_yield_render() continue var element_node = await create_element_node(element, parser, target_container) @@ -470,6 +537,7 @@ func render_content(html_bytes: PackedByteArray) -> void: # ul/ol handle their own adding if element.tag_name != "ul" and element.tag_name != "ol": safe_add_child(target_container, element_node) + await _maybe_yield_render() if contains_hyperlink(element): @@ -483,6 +551,7 @@ func render_content(html_bytes: PackedByteArray) -> void: i += 1 if scripts.size() > 0 and lua_api: + await _yield_for_ui() parser.process_scripts(lua_api, null) if parse_result.external_scripts and not parse_result.external_scripts.is_empty(): # Extract base URL without query parameters for script resolution @@ -491,15 +560,81 @@ func render_content(html_bytes: PackedByteArray) -> void: if query_pos != -1: base_url_for_scripts = base_url_for_scripts.left(query_pos) await parser.process_external_scripts(lua_api, null, base_url_for_scripts) + await _yield_for_ui() var postprocess_element = parser.process_postprocess() if postprocess_element: + await _yield_for_ui() var postprocess_node = POSTPROCESS.instantiate() add_child(postprocess_node) await postprocess_node.init(postprocess_element, parser) + await _yield_for_ui() + + rendering_tab.current_url = current_domain + rendering_tab.has_content = true + + if needs_visibility_restore and rendering_tab: + _reset_tab_container_sizes(rendering_tab) + + +func _copy_active_container_sizes_to_tab(tab: Tab) -> void: + var active_container = get_active_website_container() + if active_container and active_container.size != Vector2.ZERO: + tab.website_container.size = active_container.size + tab.website_container.custom_minimum_size = active_container.size + if tab.background_panel: + tab.background_panel.size = active_container.get_parent().size + tab.background_panel.custom_minimum_size = active_container.get_parent().size + +func _reset_tab_container_sizes(tab: Tab) -> void: + if tab.website_container: + tab.website_container.size = Vector2.ZERO + tab.website_container.custom_minimum_size = Vector2.ZERO + if tab.background_panel: + tab.background_panel.size = Vector2.ZERO + tab.background_panel.custom_minimum_size = Vector2.ZERO + +func _force_layout_update(container: Control) -> void: + if not container: + return + + _force_layout_update_recursive(container) + +func _force_layout_update_recursive(node: Control) -> void: + if not node: + return - active_tab.current_url = current_domain - active_tab.has_content = true + # Force layout calculation on layout containers + if node is FlexContainer: + node.queue_redraw() + node.update_minimum_size() + # Force flex layout recalculation + if node.has_method("queue_sort"): + node.queue_sort() + elif node is VBoxContainer or node is HBoxContainer or node is GridContainer: + node.queue_redraw() + node.update_minimum_size() + node.queue_sort() + elif node is MarginContainer: + node.queue_redraw() + node.update_minimum_size() + + # Recursively update children + for child in node.get_children(): + if child is Control: + _force_layout_update_recursive(child) + +func _reset_render_yield_state() -> void: + _render_batch_counter = 0 + +func _yield_for_ui() -> void: + await get_tree().process_frame + +func _maybe_yield_render() -> void: + _render_batch_counter += 1 + if _render_batch_counter >= RENDER_YIELD_BATCH: + await get_tree().process_frame + _render_batch_counter = 0 static func safe_add_child(parent: Node, child: Node) -> void: if child.get_parent(): @@ -676,6 +811,7 @@ func create_element_node(element: HTMLParser.HTMLElement, parser: HTMLParser, co if child_element.tag_name not in ["input", "textarea", "select", "button", "audio"]: parser.register_dom_node(child_element, child_node) safe_add_child(container_for_children, child_node) + await _maybe_yield_render() if contains_hyperlink(child_element): if child_node is RichTextLabel: diff --git a/flumi/build-scripts/flumi-workflow-installer.iss b/flumi/build-scripts/flumi-workflow-installer.iss new file mode 100644 index 0000000..61212c1 --- /dev/null +++ b/flumi/build-scripts/flumi-workflow-installer.iss @@ -0,0 +1,59 @@ +[Setup] +AppName=Flumi +AppVersion=1.0.3 +AppPublisher=Outpoot +AppPublisherURL=https://github.com/gurted/flumi +AppSupportURL=https://github.com/gurted/flumi/issues +AppUpdatesURL=https://github.com/gurted/flumi/releases +DefaultDirName={autopf}\Flumi +DefaultGroupName=Flumi +AllowNoIcons=yes +LicenseFile= +InfoBeforeFile= +InfoAfterFile= +OutputDir=Z:\work\build\Windows\installer +OutputBaseFilename=Flumi-Setup-Latest +SetupIconFile=Z:\work\flumi\Assets\gurted.ico +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 6.1; Check: not IsAdminInstallMode + +[Files] +Source: "Z:\work\build\Windows\Flumi-Windows-x86_64.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "Z:\work\build\Windows\Flumi-Windows-x86_64.pck"; DestDir: "{app}"; Flags: ignoreversion +Source: "Z:\work\build\Windows\*.dll"; DestDir: "{app}"; Flags: ignoreversion +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{group}\Flumi"; Filename: "{app}\Flumi-Windows-x86_64.exe" +Name: "{group}\{cm:UninstallProgram,Flumi}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\Flumi"; Filename: "{app}\Flumi-Windows-x86_64.exe"; Tasks: desktopicon +Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\Flumi"; Filename: "{app}\Flumi-Windows-x86_64.exe"; Tasks: quicklaunchicon + +[Registry] +Root: HKCU; Subkey: "Software\Classes\gurt"; ValueType: string; ValueName: ""; ValueData: "GURT Protocol"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\gurt"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\gurt\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\Flumi-Windows-x86_64.exe,0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\gurt\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\Flumi-Windows-x86_64.exe"" ""%1"""; Flags: uninsdeletekey + +[Run] +Filename: "{app}\Flumi-Windows-x86_64.exe"; Description: "{cm:LaunchProgram,Flumi}"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +Type: filesandordirs; Name: "{userappdata}\Flumi" + +[Code] +procedure InitializeWizard; +begin + WizardForm.LicenseAcceptedRadio.Checked := True; +end; \ No newline at end of file diff --git a/flumi/project.godot b/flumi/project.godot index f56dac5..88a9255 100644 --- a/flumi/project.godot +++ b/flumi/project.godot @@ -28,8 +28,8 @@ SettingsManager="*res://Scripts/Browser/SettingsManager.gd" [display] -window/size/viewport_width=1920 -window/size/viewport_height=1080 +window/size/viewport_width=1280 +window/size/viewport_height=720 window/stretch/aspect="ignore" [editor_plugins]