diff --git a/amulet_map_editor/api/image/icon/tablericons/arrow_control_backward.png b/amulet_map_editor/api/image/icon/tablericons/arrow_control_backward.png new file mode 100644 index 000000000..5d4d89234 Binary files /dev/null and b/amulet_map_editor/api/image/icon/tablericons/arrow_control_backward.png differ diff --git a/amulet_map_editor/api/image/icon/tablericons/arrow_control_fly_down.png b/amulet_map_editor/api/image/icon/tablericons/arrow_control_fly_down.png new file mode 100644 index 000000000..e1b2308fc Binary files /dev/null and b/amulet_map_editor/api/image/icon/tablericons/arrow_control_fly_down.png differ diff --git a/amulet_map_editor/api/image/icon/tablericons/arrow_control_fly_up.png b/amulet_map_editor/api/image/icon/tablericons/arrow_control_fly_up.png new file mode 100644 index 000000000..2e35c9e1b Binary files /dev/null and b/amulet_map_editor/api/image/icon/tablericons/arrow_control_fly_up.png differ diff --git a/amulet_map_editor/api/image/icon/tablericons/arrow_control_forward.png b/amulet_map_editor/api/image/icon/tablericons/arrow_control_forward.png new file mode 100644 index 000000000..d1d934c44 Binary files /dev/null and b/amulet_map_editor/api/image/icon/tablericons/arrow_control_forward.png differ diff --git a/amulet_map_editor/api/image/icon/tablericons/arrow_control_left.png b/amulet_map_editor/api/image/icon/tablericons/arrow_control_left.png new file mode 100644 index 000000000..97379e49b Binary files /dev/null and b/amulet_map_editor/api/image/icon/tablericons/arrow_control_left.png differ diff --git a/amulet_map_editor/api/image/icon/tablericons/arrow_control_right.png b/amulet_map_editor/api/image/icon/tablericons/arrow_control_right.png new file mode 100644 index 000000000..737498eca Binary files /dev/null and b/amulet_map_editor/api/image/icon/tablericons/arrow_control_right.png differ diff --git a/amulet_map_editor/api/wx/ui/select_world.py b/amulet_map_editor/api/wx/ui/select_world.py index 1b5564d39..e10563a87 100644 --- a/amulet_map_editor/api/wx/ui/select_world.py +++ b/amulet_map_editor/api/wx/ui/select_world.py @@ -4,40 +4,33 @@ from sys import platform from typing import List, Dict, Tuple, Callable, TYPE_CHECKING import traceback +import logging from amulet import load_format from amulet.api.errors import FormatError -from amulet_map_editor import lang, log, CONFIG +from amulet_map_editor import lang, CONFIG from amulet_map_editor.api.wx.ui import simple from amulet_map_editor.api.wx.util.ui_preferences import preserve_ui_preferences +from amulet_map_editor.api.framework import app if TYPE_CHECKING: from amulet.api.wrapper import WorldFormatWrapper +log = logging.getLogger(__name__) + # Windows %APPDATA%\.minecraft # macOS ~/Library/Application Support/minecraft # Linux ~/.minecraft - -def get_java_dir(): - if platform == "win32": - return os.path.join(os.getenv("APPDATA"), ".minecraft") - elif platform == "darwin": - return os.path.expanduser("~/Library/Application Support/minecraft") - else: - return os.path.expanduser("~/.minecraft") - - -def get_java_saves_dir(): - return os.path.join(get_java_dir(), "saves") - - -minecraft_world_paths = {lang.get("world.java_platform"): get_java_saves_dir()} +minecraft_world_paths = {} if platform == "win32": + minecraft_world_paths[lang.get("world.java_platform")] = os.path.join( + os.getenv("APPDATA"), ".minecraft", "saves" + ) minecraft_world_paths[lang.get("world.bedrock_platform")] = os.path.join( os.getenv("LOCALAPPDATA"), "Packages", @@ -47,6 +40,35 @@ def get_java_saves_dir(): "com.mojang", "minecraftWorlds", ) + minecraft_world_paths[lang.get("world.bedrock_education_store")] = os.path.join( + os.getenv("LOCALAPPDATA"), + "Packages", + "Microsoft.MinecraftEducationEdition_8wekyb3d8bbwe", + "LocalState", + "games", + "com.mojang", + "minecraftWorlds", + ) + minecraft_world_paths[lang.get("world.bedrock_education_desktop")] = os.path.join( + os.getenv("APPDATA"), + "Minecraft Education Edition", + "games", + "com.mojang", + "minecraftWorlds", + ) + minecraft_world_paths[lang.get("world.bedrock_netease")] = os.path.join( + os.getenv("APPDATA"), + "MinecraftPE_Netease", + "minecraftWorlds", + ) +elif platform == "darwin": + minecraft_world_paths[lang.get("world.java_platform")] = os.path.join( + os.path.expanduser("~"), "Library", "Application Support", "minecraft", "saves" + ) +elif platform == "linux": + minecraft_world_paths[lang.get("world.java_platform")] = os.path.join( + os.path.expanduser("~"), ".minecraft", "saves" + ) world_images: Dict[str, Tuple[int, wx.Bitmap, int]] = {} @@ -57,7 +79,7 @@ def get_world_image(image_path: str) -> Tuple[wx.Bitmap, int]: or world_images[image_path][0] != os.stat(image_path)[8] ): img = wx.Image(image_path, wx.BITMAP_TYPE_ANY) - width = min((img.GetWidth() / img.GetHeight()) * 128, 300) + width = min(int((img.GetWidth() / img.GetHeight()) * 128), 300) world_images[image_path] = ( os.stat(image_path)[8], @@ -357,3 +379,10 @@ def _close(self): self.EndModal(0) else: self.Close() + + +def open_level_from_dialog(parent: wx.Window): + """Show the open world dialog and open the selected world.""" + select_world = WorldSelectDialog(parent, app.open_level) + select_world.ShowModal() + select_world.Destroy() diff --git a/amulet_map_editor/api/wx/util/button_input.py b/amulet_map_editor/api/wx/util/button_input.py index 340eadf5a..ad4dde056 100644 --- a/amulet_map_editor/api/wx/util/button_input.py +++ b/amulet_map_editor/api/wx/util/button_input.py @@ -152,7 +152,8 @@ def is_key_pressed(self, key: KeyType): def unpress_all(self): """Unpress all keys. - This is useful if the window focus is lost because key release events will not be detected.""" + This is useful if the window focus is lost because key release events will not be detected. + """ self._pressed_keys.clear() self._clean_up_actions() @@ -243,3 +244,16 @@ def _clean_up_actions(self): def _process_continuous_inputs(self, evt): wx.PostEvent(self.window, InputHeldEvent(self._continuous_actions.copy())) + + # Programmatic control for virtual/touch inputs + def press_action(self, action_id: ActionIDType): + """Programmatically start an action as if its key was pressed.""" + if action_id in self._registered_actions and action_id not in self._continuous_actions: + self._continuous_actions.add(action_id) + wx.PostEvent(self.window, InputPressEvent(action_id)) + + def release_action(self, action_id: ActionIDType): + """Programmatically stop an action as if its key was released.""" + if action_id in self._continuous_actions: + self._continuous_actions.remove(action_id) + wx.PostEvent(self.window, InputReleaseEvent(action_id)) \ No newline at end of file diff --git a/amulet_map_editor/api/wx/util/key_config.py b/amulet_map_editor/api/wx/util/key_config.py index d3448e73a..d97db890b 100644 --- a/amulet_map_editor/api/wx/util/key_config.py +++ b/amulet_map_editor/api/wx/util/key_config.py @@ -235,7 +235,6 @@ def serialise_modifier( evt: Union[wx.KeyEvent, wx.MouseEvent], key: int ) -> ModifierType: - modifier = [] if evt.ControlDown(): if key not in (wx.WXK_SHIFT, wx.WXK_ALT): @@ -272,10 +271,9 @@ def serialise_key(evt: Union[wx.KeyEvent, wx.MouseEvent]) -> Optional[KeyType]: def serialise_key_event( - evt: Union[wx.KeyEvent, wx.MouseEvent] + evt: Union[wx.KeyEvent, wx.MouseEvent], ) -> Optional[SerialisedKeyType]: if isinstance(evt, wx.KeyEvent): - key = evt.GetUnicodeKey() or evt.GetKeyCode() if key == wx.WXK_CONTROL: return @@ -314,13 +312,21 @@ def __init__(self, parent: wx.Window, action: str): self._key = ((), "NONE") - self.Bind(wx.EVT_LEFT_DOWN, self._on_key) - self.Bind(wx.EVT_MIDDLE_DOWN, self._on_key) - self.Bind(wx.EVT_RIGHT_DOWN, self._on_key) - self.Bind(wx.EVT_KEY_DOWN, self._on_key) - self.Bind(wx.EVT_MOUSEWHEEL, self._on_key) - self.Bind(wx.EVT_MOUSE_AUX1_DOWN, self._on_key) - self.Bind(wx.EVT_MOUSE_AUX2_DOWN, self._on_key) + panel = wx.Panel(self) + panel.SetFocus() + + panel.Bind(wx.EVT_LEFT_DOWN, self._on_key) + panel.Bind(wx.EVT_MIDDLE_DOWN, self._on_key) + panel.Bind(wx.EVT_RIGHT_DOWN, self._on_key) + panel.Bind(wx.EVT_KEY_DOWN, self._on_key) + panel.Bind(wx.EVT_MOUSEWHEEL, self._on_key) + panel.Bind(wx.EVT_MOUSE_AUX1_DOWN, self._on_key) + panel.Bind(wx.EVT_MOUSE_AUX2_DOWN, self._on_key) + + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(panel, 1, wx.EXPAND) + self.SetSizer(sizer) + self.Layout() def _on_key(self, evt): key = serialise_key_event(evt) @@ -349,18 +355,31 @@ def __init__( entries: Sequence[KeyActionType], fixed_keybinds: KeybindContainer, user_keybinds: KeybindContainer, + *, + touch_controls_enabled: bool = False, ): super().__init__(parent, "Key Select") self._key_config = KeyConfig( self, selected_group, entries, fixed_keybinds, user_keybinds ) self.sizer.Add(self._key_config, 1, wx.EXPAND) + + # Touch controls toggle (placed at the bottom of this dialog) + bottom = wx.BoxSizer(wx.HORIZONTAL) + self._touch_checkbox = wx.CheckBox( + self, label="Enable touchscreen mode (experemental)" + ) + self._touch_checkbox.SetValue(bool(touch_controls_enabled)) + bottom.Add(self._touch_checkbox, 0, wx.ALL, 50) + self.sizer.Add(bottom, 0, wx.EXPAND) + self.Layout() self.Fit() @property - def options(self) -> Tuple[KeybindContainer, KeybindGroupIdType, KeybindGroup]: - return self._key_config.options + def options(self) -> Tuple[KeybindContainer, KeybindGroupIdType, KeybindGroup, bool]: + user_keybinds, group_id, keybinds = self._key_config.options + return user_keybinds, group_id, keybinds, self._touch_checkbox.GetValue() class KeyConfig(wx.BoxSizer): diff --git a/amulet_map_editor/lang/en.lang b/amulet_map_editor/lang/en.lang index 1c9a60b9a..2d3507181 100644 --- a/amulet_map_editor/lang/en.lang +++ b/amulet_map_editor/lang/en.lang @@ -217,8 +217,22 @@ program_3d_edit.goto_ui.paste_button_tooltip=Paste a previously copied coordinat program_3d_edit.file_ui.version_tooltip=Platform and data version of the world program_3d_edit.file_ui.projection_tooltip=Change view program_3d_edit.file_ui.location_tooltip=Move camera +program_3d_edit.file_ui.speed_blocks_per_second=b/s +program_3d_edit.file_ui.speed_tooltip=Camera speed in blocks per second +program_3d_edit.file_ui.speed_dialog_name=Set camera speed program_3d_edit.file_ui.dim_tooltip=Select dimension program_3d_edit.file_ui.undo_tooltip=Undo program_3d_edit.file_ui.redo_tooltip=Redo program_3d_edit.file_ui.save_tooltip=Save changes program_3d_edit.file_ui.close_tooltip=Close world + +# Touch Controls +program_3d_edit.touch_controls.toggle_label=Touch Controls +program_3d_edit.touch_controls.toggle_tooltip=Show/hide on-screen movement buttons +program_3d_edit.mouse_mode.toggle_label=Selector / Camera +program_3d_edit.mouse_mode.toggle_tooltip=Toggle between selection mode and camera rotation mode + +# Options +program_3d_edit.options.field_of_view=Field of View +program_3d_edit.options.render_distance=Render Distance +program_3d_edit.options.camera_sensitivity=Camera Sensitivity diff --git a/amulet_map_editor/lang/en.lang.bak b/amulet_map_editor/lang/en.lang.bak new file mode 100644 index 000000000..1c9a60b9a --- /dev/null +++ b/amulet_map_editor/lang/en.lang.bak @@ -0,0 +1,224 @@ +# Important notes +## If you want to contribute to the translation of the User Interface (UI), please read the corresponding contribution file located at '/contributing/lang.md' +## Each translation entry must be written as follows: 'unique.identifier=The text shown in the UI' +## Unique identifiers are defined by the devs. In order to know what are the existing identifiers, please refer to the 'en.lang' file + +# Loading order and region specific translations +## First the 'en.lang' is loaded to ensure that there is at least something for any given key +## Then, if the language code contains an "_" symbol (for example "fr_CA"), the lang file for the language section ("fr") will be loaded next ("fr.lang") +## Finally, if it exists, the region specific language file ("fr_CA.lang") will be loaded which should only contain entries that vary between regions +## This allows languages that do not vary much between regions to share the same language file to minimise duplication + +# Supported features in translation files +## You can write a comment line by using the "#" symbol as the first (non-space) character of a line. Inline comments are not supported +## Any "\n" in a translation string will be converted as a new line in the UI + +# About the menu bar +## The "&" symbol is a special character in this context. It will not be shown in the UI but will define the following character as a shortcut when the "alt" key is pressed (warning: accented letters are they own characters (é =/= e)) +## Example: "&File" translates to "File" in the UI, but will allow the user to press "alt+f" to open the corresponding menu +## More info can be found at: https://docs.wxpython.org/wx.MenuItem.html#wx.MenuItem.SetItemLabel + +# About the fstrings +## The pattern "{variable}" is used in some entries, this pattern allows the app to input values in the text +## Example: "There are {n} changes" translates to "There are 2 changes" in the UI +## This pattern must stay in the translated entries for them to work properly + +# Shared strings +shared.check_console=Check the console for more details. + +# App +app.world_still_used=A world is still being used. Please close it first +app.browser_open_tooltip=Clicking will open the page in your browser + +world.java_platform=Java +world.bedrock_platform=Bedrock +world.close_world=Close World + +# Menu bar +## The menu displayed at the top of the screen +menu_bar.file.menu_name=&File +menu_bar.file.open_world=Open World +menu_bar.file.close_world=Close World +menu_bar.file.quit=Quit +menu_bar.options.menu_name=&Options +menu_bar.help.menu_name=&Help + +# Main menu +## The start screen +main_menu.tab_name=Main Menu +main_menu.open_world=Open World +main_menu.help=Help +main_menu.discord=Amulet Discord + +# Update check +## The window displayed when a newer version of Amulet is available +update_check.newer_version_released=A new version of Amulet has been released! +update_check.new_version=New Version: +update_check.current_version=Your Version: +update_check.update=Go to Download Page +update_check.ok=Ok + +# Select world +## The window when selecting a world to open +select_world.title=World Select +select_world.open_world_warning=Close the world in game and other tools before opening in Amulet. +select_world.open_world_button=Open other world +select_world.open_world_dialogue=Select a Minecraft world directory +select_world.select_directory_failed=Failed to open directory! +select_world.recent_worlds=Recently Opened Worlds +select_world.no_loader_found=Could not find a loader for this world. +select_world.loading_world_failed=Error loading world. + +# About +## The default program when a world is opened +program_about.tab_name=About +program_about.currently_opened_world=Currently Opened World: +program_about.choose_from_options=Choose from the options on the left what you would like to do.\nYou can switch between these at any time. + +# Convert +## The program used to convert a world +program_convert.tab_name=Convert +program_convert.convert_button=Convert +program_convert.input_world=Input World: +program_convert.output_world=Output World: +program_convert.select_output_world=Select Output World +program_convert.input_output_must_different=The input and output worlds must be different! +program_convert.select_before_converting=Select a world before converting! +program_convert.conversion_completed=World conversion completed + +## Menu bar +program_convert.menu_bar.help.user_guide=User Guide (online) + +# 3D Editor +## The program used to edit a world with a 3D viewer +program_3d_edit.tab_name=3D Editor + +## Canvas +program_3d_edit.canvas.please_wait=Please wait while the renderer loads +program_3d_edit.canvas.downloading_bedrock_vanilla_resource_pack=Downloading Bedrock vanilla resource pack +program_3d_edit.canvas.downloading_java_vanilla_resource_pack=Downloading Java vanilla resource pack +program_3d_edit.canvas.java_rp_failed=Failed to download the latest Java resource pack. +program_3d_edit.canvas.java_rp_failed_default=Check your internet connection and restart Amulet. +program_3d_edit.canvas.java_rp_failed_mac_certificates=The certificates to access the internet were not installed.\nRun the "Install Certificates.command" program that can be found in "Applications/Python 3.x". +program_3d_edit.canvas.loading_resource_packs=Loading resource packs +program_3d_edit.canvas.creating_texture_atlas=Creating texture atlas +program_3d_edit.canvas.setting_up_renderer=Setting up renderer + +## Menu bar +program_3d_edit.menu_bar.file.save=Save +program_3d_edit.menu_bar.edit.menu_name=&Edit +program_3d_edit.menu_bar.edit.undo=Undo +program_3d_edit.menu_bar.edit.redo=Redo +program_3d_edit.menu_bar.edit.cut=Cut +program_3d_edit.menu_bar.edit.copy=Copy +program_3d_edit.menu_bar.edit.paste=Paste +program_3d_edit.menu_bar.edit.delete=Delete Blocks +program_3d_edit.menu_bar.edit.goto=Goto +program_3d_edit.menu_bar.edit.select_all=Select All +program_3d_edit.menu_bar.options.controls=Controls... +program_3d_edit.menu_bar.options.options=Options... +program_3d_edit.menu_bar.help.user_guide=User Guide (online) + +## Select tool +program_3d_edit.select_tool.delete_button=Delete Blocks +program_3d_edit.select_tool.delete_button_tooltip=Delete the blocks in the selected area. +program_3d_edit.select_tool.copy_button=Copy +program_3d_edit.select_tool.copy_button_tooltip=Copy the selected area to paste later. Can be pasted into any world and dimension. +program_3d_edit.select_tool.cut_button=Cut +program_3d_edit.select_tool.cut_button_tooltip=Copy the selected area to paste later and delete. Can be pasted into any world and dimension. +program_3d_edit.select_tool.paste_button=Paste +program_3d_edit.select_tool.paste_button_tooltip=Paste a previously copied or cut area into the world. +program_3d_edit.select_tool.scroll_point_x1=x1 +program_3d_edit.select_tool.scroll_point_y1=y1 +program_3d_edit.select_tool.scroll_point_z1=z1 +program_3d_edit.select_tool.scroll_point_x2=x2 +program_3d_edit.select_tool.scroll_point_y2=y2 +program_3d_edit.select_tool.scroll_point_z2=z2 +program_3d_edit.select_tool.scroll_point_x1_tooltip=Set the x coordinate of the active box's green corner. Type a number or scroll wheel while hovering. +program_3d_edit.select_tool.scroll_point_y1_tooltip=Set the y coordinate of the active box's green corner. Type a number or scroll wheel while hovering. +program_3d_edit.select_tool.scroll_point_z1_tooltip=Set the z coordinate of the active box's green corner. Type a number or scroll wheel while hovering. +program_3d_edit.select_tool.scroll_point_x2_tooltip=Set the x coordinate of the active box's blue corner. Type a number or scroll wheel while hovering. +program_3d_edit.select_tool.scroll_point_y2_tooltip=Set the y coordinate of the active box's blue corner. Type a number or scroll wheel while hovering. +program_3d_edit.select_tool.scroll_point_z2_tooltip=Set the z coordinate of the active box's blue corner. Type a number or scroll wheel while hovering. +program_3d_edit.select_tool.box_size_selector_fstring=dx={x},dy={y},dz={z} +program_3d_edit.select_tool.box_size_selector_tooltip=The shape of the active selection using Minecraft volume selector notation. +program_3d_edit.select_tool.box_size_tooltip=The size of the active selection box in blocks. +program_3d_edit.select_tool.button_point1=Move Point 1 +program_3d_edit.select_tool.button_point1_tooltip=Press and hold this button and use the movement controls to move the green selection point. +program_3d_edit.select_tool.button_point2=Move Point 2 +program_3d_edit.select_tool.button_point2_tooltip=Press and hold this button and use the movement controls to move the blue selection point. +program_3d_edit.select_tool.button_selection_box=Move Box +program_3d_edit.select_tool.button_selection_box_tooltip=Press and hold this button and use the movement controls to move the active box. + +## Paste tool +program_3d_edit.paste_tool.location_label=Location +program_3d_edit.paste_tool.location_x_label=x +program_3d_edit.paste_tool.location_x_tooltip=The x location where the centre of the selection will be placed. Type in a number, scroll wheel over or use the arrows to change. +program_3d_edit.paste_tool.location_y_label=y +program_3d_edit.paste_tool.location_y_tooltip=The y location where the centre of the selection will be placed. Type in a number, scroll wheel over or use the arrows to change. +program_3d_edit.paste_tool.location_z_label=z +program_3d_edit.paste_tool.location_z_tooltip=The z location where the centre of the selection will be placed. Type in a number, scroll wheel over or use the arrows to change. +program_3d_edit.paste_tool.move_selection_label=Move Selection +program_3d_edit.paste_tool.move_selection_tooltip=Press and hold this button and use the movement controls to move the selection. +program_3d_edit.paste_tool.rotation_label=Rotation +program_3d_edit.paste_tool.free_rotation_label=Free Rotation +program_3d_edit.paste_tool.free_rotation_tooltip=If unticked the selection can be rotated in multiples of 90 degrees. If ticked the selection can be rotated in single degree increments. +program_3d_edit.paste_tool.rotation_x_label=x +program_3d_edit.paste_tool.rotation_x_tooltip=The angle in degrees in the x axis that the selection is rotated in. Note this is the model's x axis which is transformed by the z and y rotation so this may not match the global x axis. +program_3d_edit.paste_tool.rotation_y_label=y +program_3d_edit.paste_tool.rotation_y_tooltip=The angle in degrees in the y axis that the selection is rotated in. Note this is the model's y axis which is transformed by the z rotation so this may not match the global y axis. +program_3d_edit.paste_tool.rotation_z_label=z +program_3d_edit.paste_tool.rotation_z_tooltip=The angle in degrees in the z axis that the selection is rotated in. +program_3d_edit.paste_tool.rotate_anti_clockwise_tooltip=Click to rotate the selection 90 degrees anti-clockwise relative to the look rotation. +program_3d_edit.paste_tool.rotate_clockwise_tooltip=Click to rotate the selection 90 degrees clockwise relative to the look rotation. +program_3d_edit.paste_tool.scale_label=Scale +program_3d_edit.paste_tool.scale_x_label=x +program_3d_edit.paste_tool.scale_x_tooltip=The scale of the model in the x axis. +program_3d_edit.paste_tool.scale_y_label=y +program_3d_edit.paste_tool.scale_y_tooltip=The scale of the model in the y axis. +program_3d_edit.paste_tool.scale_z_label=z +program_3d_edit.paste_tool.scale_z_tooltip=The scale of the model in the z axis. +program_3d_edit.paste_tool.mirror_horizontal_tooltip=Mirror the selection horizontally relative to the direction the camera is looking. +program_3d_edit.paste_tool.mirror_vertical_tooltip=Mirror the selection vertically relative to the direction the camera is looking. +program_3d_edit.paste_tool.copy_air_label=Paste Air +program_3d_edit.paste_tool.copy_air_tooltip=If enabled all air blocks in the pasted structure will be applied overwriting any existing blocks. If disabled the existing blocks at those locations will remain and air will not be copied. +program_3d_edit.paste_tool.copy_water_label=Paste Water +program_3d_edit.paste_tool.copy_water_tooltip=If enabled all water blocks in the pasted structure will be applied overwriting any existing blocks. If disabled the existing blocks at those locations will remain and water will not be copied. +program_3d_edit.paste_tool.copy_lava_label=Paste Lava +program_3d_edit.paste_tool.copy_lava_tooltip=If enabled all lava blocks in the pasted structure will be applied overwriting any existing blocks. If disabled the existing blocks at those locations will remain and lava will not be copied. +program_3d_edit.paste_tool.confirm_label=Confirm +program_3d_edit.paste_tool.confirm_tooltip=Click to paste the structure into the world and the specified location, rotation and scale. +program_3d_edit.paste_tool.zero_scale_message=One of the scale values had a value of zero so nothing was copied. + +## Chunk tool +program_3d_edit.chunk_tool.min_y=Min Y +program_3d_edit.chunk_tool.min_y_tooltip=The minimum y coordinate to draw in the top down view. This can be used to view a slice of the world and help view dimensions like the nether and caves. +program_3d_edit.chunk_tool.max_y=Max Y +program_3d_edit.chunk_tool.max_y_tooltip=The maximum y coordinate to draw in the top down view. This can be used to view a slice of the world and help view dimensions like the nether and caves. +program_3d_edit.chunk_tool.create_chunks=Create Empty Chunks +program_3d_edit.chunk_tool.create_chunks_tooltip=Create all chunks in the selection that do not already exist. Chunks that already exists will remain unchanged. +program_3d_edit.chunk_tool.delete_chunks=Delete Chunks +program_3d_edit.chunk_tool.delete_chunks_tooltip=Delete the selected chunks. This will delete the actual chunk and all the data contained within it. Next time you load the area in game it will recreate the chunks. +program_3d_edit.chunk_tool.prune_chunks=Delete Unselected Chunks +program_3d_edit.chunk_tool.prune_chunks_tooltip=Delete all chunks that are not selected. This will delete the actual chunk and all the data contained within it. Next time you load the area in game it will recreate the chunks. + +## Goto/Teleport window +program_3d_edit.goto_ui.title=Teleport +program_3d_edit.goto_ui.x_label=x: +program_3d_edit.goto_ui.x_label_tooltip=The x coordinate of the camera. Type a coordinate to go to the location. Ctrl + C to copy the coordinate. Ctrl + V to paste. +program_3d_edit.goto_ui.y_label=y: +program_3d_edit.goto_ui.y_label_tooltip=The y coordinate of the camera. Type a coordinate to go to the location. Ctrl + C to copy the coordinate. Ctrl + V to paste. +program_3d_edit.goto_ui.z_label=z: +program_3d_edit.goto_ui.z_label_tooltip=The z coordinate of the camera. Type a coordinate to go to the location. Ctrl + C to copy the coordinate. Ctrl + V to paste. +program_3d_edit.goto_ui.copy_button_tooltip=Copy the x, y and z values to the clipboard in the form "0.0 0.0 0.0" (x, y and z respectively) +program_3d_edit.goto_ui.paste_button_tooltip=Paste a previously copied coordinate into the inputs. Copied value must three numbers separated with spaces or commas. + +## File panel +program_3d_edit.file_ui.version_tooltip=Platform and data version of the world +program_3d_edit.file_ui.projection_tooltip=Change view +program_3d_edit.file_ui.location_tooltip=Move camera +program_3d_edit.file_ui.dim_tooltip=Select dimension +program_3d_edit.file_ui.undo_tooltip=Undo +program_3d_edit.file_ui.redo_tooltip=Redo +program_3d_edit.file_ui.save_tooltip=Save changes +program_3d_edit.file_ui.close_tooltip=Close world diff --git a/amulet_map_editor/lang/ru.lang b/amulet_map_editor/lang/ru.lang index 8e6a11637..d12765702 100644 --- a/amulet_map_editor/lang/ru.lang +++ b/amulet_map_editor/lang/ru.lang @@ -220,3 +220,8 @@ program_3d_edit.file_ui.undo_tooltip=Отменить program_3d_edit.file_ui.redo_tooltip=Вернуть program_3d_edit.file_ui.save_tooltip=Сохранить изменения program_3d_edit.file_ui.close_tooltip=Закрыть мир +# Сенсорное управление +program_3d_edit.touch_controls.toggle_label=Сенсорное управление +program_3d_edit.touch_controls.toggle_tooltip=Показать/скрыть наэкранные кнопки для передвижения +program_3d_edit.mouse_mode.toggle_label=Выделитель / Камера +program_3d_edit.mouse_mode.toggle_tooltip=Переключатель между режимом выделения области и вращения взгляда камеры \ No newline at end of file diff --git a/amulet_map_editor/programs/edit/api/behaviour/block_selection_behaviour.py b/amulet_map_editor/programs/edit/api/behaviour/block_selection_behaviour.py index aeb3da629..136444357 100644 --- a/amulet_map_editor/programs/edit/api/behaviour/block_selection_behaviour.py +++ b/amulet_map_editor/programs/edit/api/behaviour/block_selection_behaviour.py @@ -92,9 +92,9 @@ def __init__(self, canvas: "EditCanvas"): (2, 3) ) # the state of the cursor when editing starts self._highlight = False # is a box being highlighted - self._initial_box: Optional[ - NPArray2x3 - ] = None # the state of the box when editing started + self._initial_box: Optional[NPArray2x3] = ( + None # the state of the box when editing started + ) self._pointer_mask: NPArray2x3 = numpy.zeros((2, 3), dtype=bool) self._resizing = False # is a box being resized self._pointer_distance2 = 0 # the pointer distance used when resizing @@ -150,6 +150,17 @@ def _enable_inputs(self): wx.PostEvent(self.canvas, RenderBoxEnableInputsEvent()) def _on_input_press(self, evt: InputPressEvent): + # Check if we're in camera rotation mode (mouse selection mode disabled) + # If so, block all selection-related actions + if (hasattr(self.canvas, '_mouse_selection_mode') and + not self.canvas._mouse_selection_mode and + hasattr(self.canvas, '_touch_controls_enabled') and + self.canvas._touch_controls_enabled): + # In camera rotation mode with touch controls enabled, + # block all selection box operations + evt.Skip() + return + if evt.action_id == ACT_INCR_SELECT_DISTANCE: if self._resizing: self._pointer_distance2 += 1 @@ -251,6 +262,17 @@ def _on_key_press(self, evt: wx.KeyEvent): evt.Skip() def _on_input_release(self, evt: InputReleaseEvent): + # Check if we're in camera rotation mode (mouse selection mode disabled) + # If so, block all selection-related actions + if (hasattr(self.canvas, '_mouse_selection_mode') and + not self.canvas._mouse_selection_mode and + hasattr(self.canvas, '_touch_controls_enabled') and + self.canvas._touch_controls_enabled): + # In camera rotation mode with touch controls enabled, + # block all selection box operations + evt.Skip() + return + if evt.action_id == ACT_BOX_CLICK: if self._editing and time.time() - self._press_time > 0.1: self._editing = self._resizing = False @@ -301,7 +323,8 @@ def active_block_positions( self, ) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]: """Get the active box positions. - The coordinates for the maximum point of the box will be one less because this is the block position.""" + The coordinates for the maximum point of the box will be one less because this is the block position. + """ if self._active_selection is None: return (0, 0, 0), (0, 0, 0) else: @@ -314,7 +337,8 @@ def active_block_positions( ): """Set the active box positions. This should only be used when not editing. - The coordinates for the maximum point of the box will be one greater because this is the block position.""" + The coordinates for the maximum point of the box will be one greater because this is the block position. + """ if self._active_selection is not None and not self._editing: self._pointer_mask[:] = False self._start_point_1[:] = positions[0] @@ -342,7 +366,8 @@ def selection_group(self) -> SelectionGroup: def selection_group(self, selection_group: SelectionGroup): """Set the selection group of the static and active selection. This will only trigger a grapical change and will not update the global selection. - A call to push_selection is required to push the updated selection to the global selection.""" + A call to push_selection is required to push the updated selection to the global selection. + """ self._escape() if len(selection_group) == 0: # unload the active selection @@ -436,7 +461,6 @@ def _update_pointer(self): if self._active_selection is not None: self._active_selection.reset_highlight_edges() else: - self._highlight = True faces_hit = self._get_box_faces_manual( camera, look_vector, selection_group, box_index, max_distance diff --git a/amulet_map_editor/programs/edit/api/behaviour/camera_behaviour.py b/amulet_map_editor/programs/edit/api/behaviour/camera_behaviour.py index f5c187772..c9c7f4561 100644 --- a/amulet_map_editor/programs/edit/api/behaviour/camera_behaviour.py +++ b/amulet_map_editor/programs/edit/api/behaviour/camera_behaviour.py @@ -40,6 +40,8 @@ def __init__(self, canvas: "EditCanvas"): self._previous_mouse_lock = self.canvas.camera.rotating = False self._toggle_mouse_time = 0 self._last_camera_rotation: CameraRotationType = (0.0, 0.0) + self._saved_mouse_selection_mode = None + self._camera_moved_with_touch = False def bind_events(self): """Set up all events required to run.""" @@ -71,18 +73,24 @@ def _on_input_press(self, evt: InputPressEvent): self.canvas.camera.rotation = self._last_camera_rotation self.canvas.camera.projection_mode = Projection.PERSPECTIVE elif evt.action_id == ACT_CHANGE_MOUSE_MODE: - self.canvas.SetFocus() - self._previous_mouse_lock = self.canvas.camera.rotating - self._capture_mouse() - self._toggle_mouse_time = time.time() + # Only handle mouse mode changes if touch controls are not enabled + # or if we're not using the new touch control system + if not hasattr(self.canvas, '_touch_controls_enabled') or not self.canvas._touch_controls_enabled: + self.canvas.SetFocus() + self._previous_mouse_lock = self.canvas.camera.rotating + self._capture_mouse() + self._toggle_mouse_time = time.time() def _on_input_release(self, evt: InputReleaseEvent): """Logic to run each time the input release event is run.""" if evt.action_id == ACT_CHANGE_MOUSE_MODE: - if self._previous_mouse_lock or time.time() - self._toggle_mouse_time > 0.1: - self._release_mouse() - else: - self._capture_mouse() + # Only handle mouse mode changes if touch controls are not enabled + # or if we're not using the new touch control system + if not hasattr(self.canvas, '_touch_controls_enabled') or not self.canvas._touch_controls_enabled: + if self._previous_mouse_lock or time.time() - self._toggle_mouse_time > 0.1: + self._release_mouse() + else: + self._capture_mouse() elif evt.action_id == ACT_INCR_SPEED: if self.canvas.camera.projection_mode == Projection.PERSPECTIVE: self.canvas.camera.move_speed *= 1.1 @@ -93,6 +101,20 @@ def _on_input_release(self, evt: InputReleaseEvent): self.canvas.camera.move_speed /= 1.1 elif self.canvas.camera.projection_mode == Projection.TOP_DOWN: self.canvas.camera.fov = min(1000.0, self.canvas.camera.fov * 1.1) + else: + # Check if we need to restore mouse mode state after touch movement + if (self._camera_moved_with_touch and + hasattr(self.canvas, '_touch_controls_enabled') and + self.canvas._touch_controls_enabled and + hasattr(self.canvas, 'set_mouse_selection_mode') and + self._saved_mouse_selection_mode is not None): + + # Restore the saved mouse mode state + self.canvas.set_mouse_selection_mode(self._saved_mouse_selection_mode) + self._saved_mouse_selection_mode = None + self._camera_moved_with_touch = False + + evt.Skip() def _on_input_held(self, evt: InputHeldEvent): """Logic to run each time the input held event is run.""" @@ -103,6 +125,17 @@ def _on_input_held(self, evt: InputHeldEvent): ) right += (ACT_MOVE_RIGHT in evt.action_ids) - (ACT_MOVE_LEFT in evt.action_ids) + # Check if we're moving with touch controls and save mouse mode state + if (hasattr(self.canvas, '_touch_controls_enabled') and + self.canvas._touch_controls_enabled and + hasattr(self.canvas, '_mouse_selection_mode') and + any((forward, up, right))): + + # Save the current mouse mode state before movement + if self._saved_mouse_selection_mode is None: + self._saved_mouse_selection_mode = self.canvas._mouse_selection_mode + self._camera_moved_with_touch = True + if self.canvas.camera.projection_mode == Projection.PERSPECTIVE: if self.canvas.camera.rotating: pitch = self.canvas.mouse.delta_y * 0.07 @@ -178,6 +211,9 @@ def _release_mouse(self): def _on_loss_focus(self, evt): """Event fired when the user tabs out of the window.""" self._escape() + # Clear touch movement tracking state + self._saved_mouse_selection_mode = None + self._camera_moved_with_touch = False evt.Skip() def _escape(self): @@ -185,3 +221,6 @@ def _escape(self): # self._persistent_actions.clear() self.canvas.buttons.unpress_all() self._release_mouse() + # Clear touch movement tracking state + self._saved_mouse_selection_mode = None + self._camera_moved_with_touch = False diff --git a/amulet_map_editor/programs/edit/api/behaviour/pointer_behaviour.py b/amulet_map_editor/programs/edit/api/behaviour/pointer_behaviour.py index d4240375b..2e2286184 100644 --- a/amulet_map_editor/programs/edit/api/behaviour/pointer_behaviour.py +++ b/amulet_map_editor/programs/edit/api/behaviour/pointer_behaviour.py @@ -66,6 +66,17 @@ def bind_events(self): self.canvas.Bind(EVT_INPUT_PRESS, self._on_input_press) def _on_input_press(self, evt: InputPressEvent): + # Check if we're in camera rotation mode (mouse selection mode disabled) + # If so, block all selection-related actions + if (hasattr(self.canvas, '_mouse_selection_mode') and + not self.canvas._mouse_selection_mode and + hasattr(self.canvas, '_touch_controls_enabled') and + self.canvas._touch_controls_enabled): + # In camera rotation mode with touch controls enabled, + # block all selection pointer operations + evt.Skip() + return + if evt.action_id == ACT_INCR_SELECT_DISTANCE: self._pointer_distance += 1 self._pointer_moved = True @@ -75,6 +86,17 @@ def _on_input_press(self, evt: InputPressEvent): evt.Skip() def _invalidate_pointer(self, evt): + # Check if we're in camera rotation mode (mouse selection mode disabled) + # If so, don't update the pointer for selection purposes + if (hasattr(self.canvas, '_mouse_selection_mode') and + not self.canvas._mouse_selection_mode and + hasattr(self.canvas, '_touch_controls_enabled') and + self.canvas._touch_controls_enabled): + # In camera rotation mode with touch controls enabled, + # don't update the selection pointer + evt.Skip() + return + self._pointer_moved = True evt.Skip() diff --git a/amulet_map_editor/programs/edit/api/canvas/edit_canvas.py b/amulet_map_editor/programs/edit/api/canvas/edit_canvas.py index 260e505d1..11d0bac03 100644 --- a/amulet_map_editor/programs/edit/api/canvas/edit_canvas.py +++ b/amulet_map_editor/programs/edit/api/canvas/edit_canvas.py @@ -1,7 +1,9 @@ +import logging +import warnings import wx from typing import Callable, TYPE_CHECKING, Any, Generator, Optional from types import GeneratorType -from threading import RLock +from threading import RLock, Thread from .base_edit_canvas import BaseEditCanvas from ...edit import EDIT_CONFIG_ID @@ -19,14 +21,16 @@ from amulet.api.structure import structure_cache from amulet.api.level import BaseLevel -from amulet_map_editor import CONFIG, log +from amulet_map_editor import CONFIG +from amulet_map_editor import close_level from amulet_map_editor.api.wx.ui.traceback_dialog import TracebackDialog from amulet_map_editor.programs.edit.api.ui.goto import show_goto from amulet_map_editor.programs.edit.api.ui.tool_manager import ToolManagerSizer -from amulet_map_editor.programs.edit.api.operations import ( +from amulet_map_editor.programs.edit.api.operations.errors import ( OperationError, - OperationSuccessful, OperationSilentAbort, + BaseLoudException, + BaseSilentException, ) from amulet_map_editor.programs.edit.plugins.operations.stock_plugins.internal_operations import ( cut, @@ -39,7 +43,6 @@ RedoEvent, CreateUndoEvent, SaveEvent, - PasteEvent, ToolChangeEvent, EVT_EDIT_CLOSE, ) @@ -48,10 +51,14 @@ if TYPE_CHECKING: from amulet.api.level import BaseLevel +log = logging.getLogger(__name__) +OperationType = Callable[[], OperationReturnType] + def show_loading_dialog( - run: Callable[[], OperationReturnType], title: str, message: str, parent: wx.Window + run: OperationType, title: str, message: str, parent: wx.Window ) -> Any: + warnings.warn("show_loading_dialog is depreciated.", DeprecationWarning) dialog = wx.ProgressDialog( title, message, @@ -76,7 +83,9 @@ def show_loading_dialog( if len(progress) >= 1: progress = progress[0] if isinstance(progress, (int, float)) and isinstance(message, str): - dialog.Update(min(9999, max(0, progress * 10_000)), message) + dialog.Update( + min(9999, max(0, int(progress * 10_000))), message + ) wx.Yield() except StopIteration as e: obj = e.value @@ -88,34 +97,89 @@ def show_loading_dialog( return obj +class OperationThread(Thread): + # The operation to run + _operation: OperationType + + # Should the operation be stopped. Set externally + stop: bool + # The starting message for the progress dialog + message: str + # The operation progress (from 0-1) + progress: float + # The return value from the operation + out: Any + # The error raised if any + error: Optional[BaseException] + + def __init__(self, operation: OperationType, message: str): + super().__init__() + self._operation = operation + self.stop = False + self.message = message + self.progress = 0.0 + self.out = None + self.error = None + + def run(self) -> None: + t = time.time() + try: + obj = self._operation() + if isinstance(obj, GeneratorType): + try: + while True: + if self.stop: + raise OperationSilentAbort + progress = next(obj) + if isinstance(progress, (list, tuple)): + if len(progress) >= 2: + self.message = progress[1] + if len(progress) >= 1: + self.progress = progress[0] + elif isinstance(progress, (int, float)): + self.progress = progress + except StopIteration as e: + self.out = e.value + except BaseException as e: + self.error = e + time.sleep(max(0.2 - time.time() + t, 0)) + + class EditCanvas(BaseEditCanvas): - def __init__(self, parent: wx.Window, world: "BaseLevel", close_callback: Callable): + def __init__(self, parent: wx.Window, world: "BaseLevel"): super().__init__(parent, world) - self._close_callback = close_callback self._file_panel: Optional[FilePanel] = None self._tool_sizer: Optional[ToolManagerSizer] = None self.buttons.register_actions(self.key_binds) + self._canvas_sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(self._canvas_sizer) + # Tracks if an operation has been started and not finished. self._operation_running = False # This lock stops two threads from editing the world simultaneously # call run_operation to acquire it. self._edit_lock = RLock() - def _setup(self) -> Generator[OperationYieldType, None, None]: - yield from super()._setup() - canvas_sizer = wx.BoxSizer(wx.VERTICAL) - self.SetSizer(canvas_sizer) - + # Touchscreen mode (global) and touch controls overlay (per-session) + self._touchscreen_mode = bool( + CONFIG.get(EDIT_CONFIG_ID, {}).get("options", {}).get("touch_controls", False) + ) + # On-screen movement buttons visibility (controlled by toolbar checkbox) + self._touch_controls_enabled = bool(self._touchscreen_mode) + self._touch_panel_left: Optional[wx.Panel] = None + self._touch_panel_right: Optional[wx.Panel] = None + # When touch controls are enabled, default to selection mode (mouse mode enabled) + # When touch controls are disabled, default to camera rotation mode (mouse mode disabled) + self._mouse_selection_mode = not self._touch_controls_enabled # True = selection mode, False = camera rotation mode + self._build_touch_overlay() + + def _init_opengl(self): + super()._init_opengl() self._file_panel = FilePanel(self) - canvas_sizer.Add(self._file_panel, 0, wx.EXPAND, 0) - + self._canvas_sizer.Add(self._file_panel, 0, wx.EXPAND, 0) self._tool_sizer = ToolManagerSizer(self) - canvas_sizer.Add(self._tool_sizer, 1, wx.EXPAND, 0) - - def _finalise(self): - super()._finalise() - self._tool_sizer.enable() + self._canvas_sizer.Add(self._tool_sizer, 1, wx.EXPAND, 0) def bind_events(self): """Set up all events required to run. @@ -125,17 +189,57 @@ def bind_events(self): super().bind_events() self._file_panel.bind_events() self.Bind(EVT_EDIT_CLOSE, self._on_close) + + # Ensure touch controls stay on top when other UI elements change + self.Bind(wx.EVT_WINDOW_CREATE, self._on_window_create) + self.Bind(wx.EVT_CHILD_FOCUS, self._on_child_focus) def enable(self): super().enable() self._tool_sizer.enable() + # Initialize touch controls state + if hasattr(self, '_file_panel'): + self._file_panel.update_touch_toggles() + + # Initialize touch buttons + if hasattr(self, '_touch_buttons'): + self._position_touch_overlay() + for btn in self._touch_buttons.values(): + btn.Show(self._touch_controls_enabled) + + # Ensure touch controls are on top when enabled + if self._touch_controls_enabled: + self._ensure_touch_controls_on_top() def disable(self): super().disable() self._tool_sizer.disable() + # Hide touch buttons + if hasattr(self, '_touch_buttons'): + for btn in self._touch_buttons.values(): + btn.Hide() def _on_close(self, _): - self._close_callback() + close_level(self.world.level_path) + + def _on_window_create(self, evt): + """Ensure touch controls stay on top when new windows are created.""" + if self._touch_controls_enabled: + wx.CallAfter(self._ensure_touch_controls_on_top) + evt.Skip() + + def _on_child_focus(self, evt): + """Ensure touch controls stay on top when child windows get focus.""" + if self._touch_controls_enabled: + wx.CallAfter(self._ensure_touch_controls_on_top) + evt.Skip() + + def _ensure_touch_controls_on_top(self): + """Force touch controls to be on top of other UI elements.""" + if hasattr(self, '_touch_buttons'): + for btn in self._touch_buttons.values(): + if btn.IsShown(): + btn.Raise() @property def tools(self): @@ -153,79 +257,280 @@ def key_binds(self) -> KeybindGroup: else: return DefaultKeys + def set_touch_controls_enabled(self, enabled: bool): + """Show or hide on-screen movement buttons. + Does not affect visibility of toolbar controls; that is controlled by touchscreen mode. + """ + self._touch_controls_enabled = bool(enabled) + + # Show/hide touch buttons (only if touchscreen mode is enabled) + if hasattr(self, '_touch_buttons'): + for btn in self._touch_buttons.values(): + btn.Show(self._touchscreen_mode and self._touch_controls_enabled) + + # When touch controls are enabled, default to selection mode (mouse mode enabled) + # When touch controls are disabled, default to camera rotation mode (mouse mode disabled) + if not hasattr(self, '_mouse_selection_mode_initialized') or not self._mouse_selection_mode_initialized: + self._mouse_selection_mode = not self._touch_controls_enabled + self._mouse_selection_mode_initialized = True + # Apply the initial mouse mode setting + self.set_mouse_selection_mode(self._mouse_selection_mode) + + # Ensure touch controls are positioned correctly and on top + if enabled and self._touchscreen_mode: + self._position_touch_overlay() + + self.Layout() + + # Update the toggle in the top toolbar (FilePanel) + if hasattr(self, '_file_panel'): + self._file_panel.update_touch_toggles() + + def set_touchscreen_mode(self, enabled: bool): + """Enable/disable touchscreen mode (global). + Controls whether the toolbar Touch Controls/Selector button is visible and + whether on-screen movement buttons can be shown at all. + """ + self._touchscreen_mode = bool(enabled) + # If disabling touchscreen mode, also hide on-screen buttons + if not self._touchscreen_mode: + self._touch_controls_enabled = False + # Apply visibility to on-screen buttons + if hasattr(self, '_touch_buttons'): + for btn in self._touch_buttons.values(): + btn.Show(self._touchscreen_mode and self._touch_controls_enabled) + # Update toolbar controls visibility/state + if hasattr(self, '_file_panel'): + self._file_panel.update_touch_toggles() + self.Layout() + + def set_mouse_selection_mode(self, selection_mode: bool): + """Set mouse mode: True for selection mode, False for camera rotation mode.""" + self._mouse_selection_mode = bool(selection_mode) + # Update camera behavior based on mode + if hasattr(self, 'camera') and hasattr(self.camera, 'rotating'): + if not selection_mode: + # Camera rotation mode - enable mouse rotation + self.camera.rotating = True + self.SetCursor(wx.Cursor(wx.CURSOR_BLANK)) + else: + # Selection mode - disable mouse rotation + self.camera.rotating = False + self.SetCursor(wx.NullCursor) + + # Update the toggle in the top toolbar (FilePanel) + if hasattr(self, '_file_panel'): + self._file_panel.update_touch_toggles() + + def _build_touch_overlay(self): + try: + import amulet_map_editor.api.image as image + except Exception: + image = None + + def make_btn(icon_attr: str, action_id: str, pos: tuple): + size = 56 + if image is not None and hasattr(image.icon, "tablericons") and hasattr(image.icon.tablericons, icon_attr): + bmp = getattr(image.icon.tablericons, icon_attr).bitmap(size, size) + btn = wx.ToggleButton(self, pos=pos, size=(size + 8, size + 8)) + try: + btn.SetBitmap(bmp) + except Exception: + # Fallback: show a text label if bitmap can't be set + btn.SetLabel(icon_attr) + else: + btn = wx.ToggleButton(self, pos=pos, size=(size + 8, size + 8), label=icon_attr) + + def on_toggle(evt): + if btn.GetValue(): + try: + self.buttons.press_action(action_id) + except Exception: + wx.PostEvent(self, InputPressEvent(action_id)) + else: + try: + self.buttons.release_action(action_id) + except Exception: + wx.PostEvent(self, InputReleaseEvent(action_id)) + evt.Skip() + + btn.Bind(wx.EVT_TOGGLEBUTTON, on_toggle) + return btn + + from amulet_map_editor.programs.edit.api.key_config import ( + ACT_MOVE_FORWARDS, + ACT_MOVE_BACKWARDS, + ACT_MOVE_LEFT, + ACT_MOVE_RIGHT, + ACT_MOVE_UP, + ACT_MOVE_DOWN, + ) + from amulet_map_editor.api.wx.util.button_input import ( + InputPressEvent, + InputReleaseEvent, + ) + + # Create buttons directly on canvas without panels + # We'll position them in _position_touch_overlay + self._touch_buttons = { + 'left': make_btn("arrow_control_left", ACT_MOVE_LEFT, (0, 0)), + 'right': make_btn("arrow_control_right", ACT_MOVE_RIGHT, (0, 0)), + 'forward': make_btn("arrow_control_forward", ACT_MOVE_FORWARDS, (0, 0)), + 'back': make_btn("arrow_control_backward", ACT_MOVE_BACKWARDS, (0, 0)), + 'up': make_btn("arrow_control_fly_up", ACT_MOVE_UP, (0, 0)), + 'down': make_btn("arrow_control_fly_down", ACT_MOVE_DOWN, (0, 0)) + } + + # Hide all buttons initially + for btn in self._touch_buttons.values(): + btn.Hide() + + # Store references for compatibility + self._touch_panel_left = None + self._touch_panel_right = None + + # keep overlay positioned over the canvas corners + self.Bind(wx.EVT_SIZE, self._on_canvas_resize_overlay) + + def _on_canvas_resize_overlay(self, evt): + self._position_touch_overlay() + evt.Skip() + + def _position_touch_overlay(self): + if hasattr(self, '_touch_buttons'): + margin = 10 + btn_size = 64 + spacing = 8 # Increased spacing between buttons to prevent overlap + cw, ch = self.GetClientSize() + + # Position left cluster (WASD pattern) - moved right to avoid overlapping with left panel + left_x = margin + 150 # Increased margin further to avoid left panel overlap + left_y = ch - (btn_size * 3 + spacing * 2) - margin + + # Row 1: Forward button (center) + self._touch_buttons['forward'].SetPosition((left_x + btn_size + spacing, left_y)) + + # Row 2: Left and Right buttons - increased spacing to prevent overlap + self._touch_buttons['left'].SetPosition((left_x, left_y + btn_size + spacing)) + self._touch_buttons['right'].SetPosition((left_x + (btn_size + spacing) * 2, left_y + btn_size + spacing)) + + # Row 3: Back button (center) + self._touch_buttons['back'].SetPosition((left_x + btn_size + spacing, left_y + (btn_size + spacing) * 2)) + + # Position right cluster (Up/Down) - restore original spacing + right_x = cw - btn_size - margin + right_y = ch - (btn_size * 2 + btn_size) - margin # Space for up, gap (one button size), down + + self._touch_buttons['up'].SetPosition((right_x, right_y)) + self._touch_buttons['down'].SetPosition((right_x, right_y + btn_size * 2)) # Skip one button space between up and down + + # Ensure touch controls are always on top of other UI elements + for btn in self._touch_buttons.values(): + btn.Raise() + def _deselect(self): # TODO: Re-implement this self._tool_sizer.enable_default_tool() def run_operation( self, - operation: Callable[[], OperationReturnType], + operation: OperationType, title="Amulet", msg="Running Operation", throw_exceptions=False, + ) -> Any: + try: + out = self._run_operation(operation, title, msg, True) + except BaseException as e: + if throw_exceptions: + raise e + else: + # If there were no errors create an undo point + def create_undo(): + yield 0, "Creating Undo Point" + yield from self.create_undo_point_iter() + + self._run_operation(create_undo, title, msg, False) + + return out + + def _run_operation( + self, + operation: OperationType, + title: str, + msg: str, + cancelable: bool, ) -> Any: with self._edit_lock: if self._operation_running: raise Exception( "run_operation cannot be called from within itself. " - "This function has already been called by parent code so you do not need to run it again" + "This function has already been called by parent code so you cannot run it again" ) self._operation_running = True - def operation_wrapper(): - yield 0, "Disabling Threads" - self.renderer.disable_threads() - yield 0, msg - op = operation() - if isinstance(op, GeneratorType): - yield from op - yield 0, "Creating Undo Point" - yield from self.create_undo_point_iter() - return op - - err = None - out = None - try: - out = show_loading_dialog( - operation_wrapper, - title, - msg, - self, - ) - except OperationError as e: - msg = f"Error running operation: {e}" - log.info(msg) - self.world.restore_last_undo_point() - wx.MessageDialog(self, msg, style=wx.OK).ShowModal() - err = e - except OperationSuccessful as e: - msg = str(e) - log.info(msg) - self.world.restore_last_undo_point() - wx.MessageDialog(self, msg, style=wx.OK).ShowModal() - err = e - except OperationSilentAbort as e: - self.world.restore_last_undo_point() - err = e - except Exception as e: - log.error(traceback.format_exc()) - dialog = TracebackDialog( - self, - "Exception while running operation", - str(e), - traceback.format_exc(), - ) - dialog.ShowModal() - dialog.Destroy() - err = e + self.renderer.disable_threads() + + style = ( + wx.PD_APP_MODAL + | wx.PD_ELAPSED_TIME + | wx.PD_REMAINING_TIME + | wx.PD_AUTO_HIDE + | (wx.PD_CAN_ABORT * cancelable) + ) + dialog = wx.ProgressDialog( + title, + msg, + maximum=10_000, + parent=self, + style=style, + ) + dialog.Fit() + + # Set up a thread to run the actual operation + op = OperationThread(operation, msg) + # run the operation + op.start() + while op.is_alive(): + op.join(0.1) + dialog.Update(max(0, min(int(op.progress * 10_000), 9999)), op.message) + wx.Yield() + if dialog.WasCancelled(): + op.stop = True + + dialog.Destroy() + wx.Yield() + + if op.error is not None: + # If there is any kind of error restore the last undo point self.world.restore_last_undo_point() + if isinstance(op.error, BaseLoudException): + msg = str(op.error) + if isinstance(op.error, OperationError): + msg = f"Error running operation: {msg}" + log.info(msg) + wx.MessageDialog(self, msg, style=wx.OK).ShowModal() + elif isinstance(op.error, BaseSilentException): + pass + elif isinstance(op.error, BaseException): + log.error(traceback.format_exc()) + dialog = TracebackDialog( + self, + "Exception while running operation", + str(op.error), + traceback.format_exc(), + ) + dialog.ShowModal() + dialog.Destroy() + self.world.restore_last_undo_point() + self.renderer.enable_threads() self.renderer.render_world.rebuild_changed() self._operation_running = False - if err is not None and throw_exceptions: - raise err - return out + if op.error is not None: + raise op.error + return op.out def create_undo_point(self, world=True, non_world=True): self.world.create_undo_point(world, non_world) @@ -265,8 +570,12 @@ def paste(self, structure: BaseLevel, dimension: Dimension): assert ( dimension in structure.dimensions ), "The requested dimension does not exist for this object." - wx.PostEvent(self, ToolChangeEvent(tool="Paste")) - wx.PostEvent(self, PasteEvent(structure=structure, dimension=dimension)) + wx.PostEvent( + self, + ToolChangeEvent( + tool="Paste", state={"structure": structure, "dimension": dimension} + ), + ) def paste_from_cache(self): if structure_cache: @@ -317,8 +626,6 @@ def select_all(self): self.selection.selection_corners = [] def save(self): - self.renderer.disable_threads() - def save(): yield 0, "Running Pre-Save Operations." pre_save_op = self.world.pre_save_operation() @@ -335,6 +642,5 @@ def save(): for chunk_index, chunk_count in self.world.save_iter(): yield chunk_index / chunk_count - show_loading_dialog(save, "Saving world.", "Please wait.", self) + self._run_operation(save, "Saving world.", "Please wait.", False) wx.PostEvent(self, SaveEvent()) - self.renderer.enable_threads() diff --git a/amulet_map_editor/programs/edit/api/ui/file.py b/amulet_map_editor/programs/edit/api/ui/file.py index 3e76cef0e..2be6f689d 100644 --- a/amulet_map_editor/programs/edit/api/ui/file.py +++ b/amulet_map_editor/programs/edit/api/ui/file.py @@ -1,4 +1,5 @@ from typing import TYPE_CHECKING, Optional +from math import floor, log10 import wx from amulet_map_editor.programs.edit.api.edit_canvas_container import ( @@ -7,6 +8,7 @@ from amulet_map_editor.api.wx.ui.simple import SimpleChoiceAny from amulet_map_editor.programs.edit.api.events import ( EVT_CAMERA_MOVED, + EVT_SPEED_CHANGED, EVT_UNDO, EVT_REDO, EVT_CREATE_UNDO, @@ -23,6 +25,13 @@ from amulet_map_editor.programs.edit.api.canvas import EditCanvas +def _format_float(num: float) -> str: + if num < 100: + return f"{num:.0{max(0, 2 - floor(log10(num)))}f}".rstrip("0").rstrip(".") + else: + return f"{num:.0f}" + + class FilePanel(wx.BoxSizer, EditCanvasContainer): def __init__(self, canvas: "EditCanvas"): wx.BoxSizer.__init__(self, wx.HORIZONTAL) @@ -38,6 +47,33 @@ def __init__(self, canvas: "EditCanvas"): ) self.Add(self._version_text, 0) self.AddStretchSpacer(1) + + # Touch controls and mouse mode controls (to the LEFT of the 3D button) + # Touch controls toggle as a sticky (toggle) button + self._touch_controls_checkbox = wx.ToggleButton( + canvas, label=lang.get("program_3d_edit.touch_controls.toggle_label") + ) + self._touch_controls_checkbox.SetToolTip( + lang.get("program_3d_edit.touch_controls.toggle_tooltip") + ) + # We'll set the min height to match the projection button after it is created + self.Add( + self._touch_controls_checkbox, + 0, + wx.TOP | wx.BOTTOM | wx.RIGHT | wx.CENTER, + 5, + ) + + # Selector/Camera mode toggle as a button (like 3D/2D) + self._mouse_mode_button = wx.Button(canvas, label="Selector") + self._mouse_mode_button.SetToolTip( + lang.get("program_3d_edit.mouse_mode.toggle_tooltip") + ) + self.Add( + self._mouse_mode_button, 0, wx.TOP | wx.BOTTOM | wx.RIGHT | wx.CENTER, 5 + ) + + # 3D/2D projection button self._projection_button = wx.Button(canvas, label="3D") self._projection_button.SetToolTip( lang.get("program_3d_edit.file_ui.projection_tooltip") @@ -46,6 +82,12 @@ def __init__(self, canvas: "EditCanvas"): self.Add( self._projection_button, 0, wx.TOP | wx.BOTTOM | wx.RIGHT | wx.CENTER, 5 ) + + # Bind events + self._touch_controls_checkbox.Bind( + wx.EVT_TOGGLEBUTTON, self._on_touch_controls_toggle + ) + self._mouse_mode_button.Bind(wx.EVT_BUTTON, self._on_mouse_mode_button) self._location_button = wx.Button( canvas, label=", ".join([f"{s:.2f}" for s in self.canvas.camera.location]) ) @@ -53,9 +95,23 @@ def __init__(self, canvas: "EditCanvas"): lang.get("program_3d_edit.file_ui.location_tooltip") ) self._location_button.Bind(wx.EVT_BUTTON, lambda evt: self.canvas.goto()) - self.Add(self._location_button, 0, wx.TOP | wx.BOTTOM | wx.RIGHT | wx.CENTER, 5) + def set_speed(evt): + dialog = SpeedSelectDialog( + canvas, self.canvas.camera.move_speed * 1000 / 33 + ) + if dialog.ShowModal() == wx.ID_OK: + self.canvas.camera.move_speed = dialog.speed * 33 / 1000 + + self._speed_button = wx.Button( + canvas, + label=f"{_format_float(self.canvas.camera.move_speed * 1000 / 33)} {lang.get('program_3d_edit.file_ui.speed_blocks_per_second')}", + ) + self._speed_button.SetToolTip(lang.get("program_3d_edit.file_ui.speed_tooltip")) + self._speed_button.Bind(wx.EVT_BUTTON, set_speed) + self.Add(self._speed_button, 0, wx.TOP | wx.BOTTOM | wx.RIGHT | wx.CENTER, 5) + self._dim_options = SimpleChoiceAny(canvas) self._dim_options.SetToolTip(lang.get("program_3d_edit.file_ui.dim_tooltip")) self._dim_options.SetItems(level.level_wrapper.dimensions) @@ -100,11 +156,40 @@ def create_button(text, operation): self.Add(close_button, 0, wx.TOP | wx.BOTTOM | wx.RIGHT, 5) self._update_buttons() + + # Ensure the touch checkbox height matches button height + try: + btn_h = self._projection_button.GetSize().height + cur_w, _ = self._touch_controls_checkbox.GetSize() + self._touch_controls_checkbox.SetMinSize((max(cur_w, 110), btn_h)) + except Exception: + pass + # Initialize touch controls UI if canvas is ready + try: + if hasattr(self.canvas, '_touch_controls_enabled') and hasattr( + self.canvas, '_mouse_selection_mode' + ): + self.update_touch_toggles() + else: + # Set default values if canvas is not ready + self._touch_controls_checkbox.SetValue(False) + self._mouse_mode_button.SetLabel("Selector") + self._touch_controls_checkbox.Show(False) + self._mouse_mode_button.Show(False) + except Exception as e: + print(f"Error initializing touch toggles in constructor: {e}") + # Set default values and show toggles + self._touch_controls_checkbox.SetValue(False) + self._mouse_mode_button.SetLabel("Selector") + self._touch_controls_checkbox.Show(False) + self._mouse_mode_button.Show(False) + self.Layout() def bind_events(self): self.canvas.Bind(EVT_CAMERA_MOVED, self._on_camera_move) + self.canvas.Bind(EVT_SPEED_CHANGED, self._on_speed_change) self.canvas.Bind(EVT_UNDO, self._on_update_buttons) self.canvas.Bind(EVT_REDO, self._on_update_buttons) self.canvas.Bind(EVT_SAVE, self._on_update_buttons) @@ -146,7 +231,8 @@ def _on_projection_button(self, evt): def _change_dimension(self, evt: DimensionChangeEvent): """Run when the dimension attribute in the canvas is changed. - This is run when the user changes the attribute and when it is changed manually in code.""" + This is run when the user changes the attribute and when it is changed manually in code. + """ dimension = evt.dimension index = self._dim_options.FindString(dimension) if not (index == wx.NOT_FOUND or index == self._dim_options.GetSelection()): @@ -160,3 +246,111 @@ def _on_camera_move(self, evt): if len(label) != len(old_label): self.canvas.Layout() evt.Skip() + + def _on_speed_change(self, evt): + label = f"{_format_float(self.canvas.camera.move_speed * 1000 / 33)} {lang.get('program_3d_edit.file_ui.speed_blocks_per_second')}" + old_label = self._speed_button.GetLabel() + self._speed_button.SetLabel(label) + if len(label) != len(old_label): + self.canvas.Layout() + evt.Skip() + + def _on_touch_controls_toggle(self, evt): + """Handle touch controls visibility toggle.""" + enabled = self._touch_controls_checkbox.GetValue() + if hasattr(self.canvas, 'set_touch_controls_enabled'): + self.canvas.set_touch_controls_enabled(enabled) + evt.Skip() + + def _on_mouse_mode_button(self, evt): + """Toggle between Selector and Camera modes via a button.""" + if hasattr(self.canvas, '_mouse_selection_mode'): + new_mode = not bool(self.canvas._mouse_selection_mode) + if hasattr(self.canvas, 'set_mouse_selection_mode'): + self.canvas.set_mouse_selection_mode(new_mode) + evt.Skip() + + def update_touch_toggles(self): + """Public method to update toggle states from external changes.""" + try: + if hasattr(self.canvas, '_touch_controls_enabled'): + self._touch_controls_checkbox.SetValue(self.canvas._touch_controls_enabled) + else: + self._touch_controls_checkbox.SetValue(False) + # Update label to reflect current mode + if hasattr(self.canvas, '_mouse_selection_mode') and self.canvas._mouse_selection_mode: + self._mouse_mode_button.SetLabel("Selector") + else: + self._mouse_mode_button.SetLabel("Camera") + + # Show or hide the controls based on global touchscreen mode + show_controls = bool(getattr(self.canvas, '_touchscreen_mode', False)) + self._touch_controls_checkbox.Show(show_controls) + self._mouse_mode_button.Show(show_controls) + + self.Layout() + except Exception as e: + print(f"Error in update_touch_toggles: {e}") + # Hide toggles if there's an error + if hasattr(self, '_touch_controls_checkbox'): + self._touch_controls_checkbox.Show(False) + if hasattr(self, '_mouse_mode_button'): + self._mouse_mode_button.Show(False) + + +class SpeedSelectDialog(wx.Dialog): + def __init__(self, parent: wx.Window, speed: float): + wx.Dialog.__init__(self, parent) + self.SetTitle(lang.get("program_3d_edit.file_ui.speed_dialog_name")) + + sizer = wx.BoxSizer(wx.VERTICAL) + + self._speed_spin_ctrl_double = wx.SpinCtrlDouble( + self, wx.ID_ANY, initial=speed, min=0.0, max=1_000_000_000.0 + ) + self._speed_spin_ctrl_double.SetToolTip( + lang.get("program_3d_edit.file_ui.speed_tooltip") + ) + + def on_mouse_wheel(evt: wx.MouseEvent): + if evt.GetWheelRotation() > 0: + self._speed_spin_ctrl_double.SetValue( + self._speed_spin_ctrl_double.GetValue() + + self._speed_spin_ctrl_double.GetIncrement() + ) + else: + self._speed_spin_ctrl_double.SetValue( + self._speed_spin_ctrl_double.GetValue() + - self._speed_spin_ctrl_double.GetIncrement() + ) + + self._speed_spin_ctrl_double.Bind(wx.EVT_MOUSEWHEEL, on_mouse_wheel) + self._speed_spin_ctrl_double.SetIncrement(1.0) + self._speed_spin_ctrl_double.SetDigits(4) + sizer.Add( + self._speed_spin_ctrl_double, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5 + ) + + button_sizer = wx.StdDialogButtonSizer() + sizer.Add(button_sizer, 0, wx.ALIGN_RIGHT | wx.ALL, 4) + + self._button_ok = wx.Button(self, wx.ID_OK, "") + self._button_ok.SetDefault() + button_sizer.AddButton(self._button_ok) + + self._button_cancel = wx.Button(self, wx.ID_CANCEL, "") + button_sizer.AddButton(self._button_cancel) + + button_sizer.Realize() + + self.SetSizer(sizer) + sizer.Fit(self) + + self.SetAffirmativeId(self._button_ok.GetId()) + self.SetEscapeId(self._button_cancel.GetId()) + + self.Layout() + + @property + def speed(self) -> float: + return self._speed_spin_ctrl_double.GetValue() diff --git a/amulet_map_editor/programs/edit/api/ui/tool_manager.py b/amulet_map_editor/programs/edit/api/ui/tool_manager.py index 5a3c65556..8473d7b75 100644 --- a/amulet_map_editor/programs/edit/api/ui/tool_manager.py +++ b/amulet_map_editor/programs/edit/api/ui/tool_manager.py @@ -10,6 +10,8 @@ ToolChangeEvent, EVT_TOOL_CHANGE, ) +from amulet_map_editor import lang +from amulet_map_editor.programs.edit.edit import EDIT_CONFIG_ID from amulet_map_editor.programs.edit.plugins.tools import ( ImportTool, @@ -73,9 +75,8 @@ def register_tool(self, tool_cls: Type[BaseToolUIType]): self._tools[tool.name] = tool self._tool_option_sizer.Add(tool, 1, wx.EXPAND, 0) - def _enable_tool_event(self, evt): - self._enable_tool(evt.tool) - # evt.Skip() # this causes issues if uncommented + def _enable_tool_event(self, evt: ToolChangeEvent): + self._enable_tool(evt.tool, evt.state) def enable(self): if isinstance(self._active_tool, SelectTool): @@ -97,7 +98,7 @@ def enable_default_tool(self): if not isinstance(self._active_tool, SelectTool): self._enable_tool("Select") - def _enable_tool(self, tool: str): + def _enable_tool(self, tool: str, state=None): if tool in self._tools: if self._active_tool is not None: self._active_tool.disable() @@ -111,6 +112,7 @@ def _enable_tool(self, tool: str): elif isinstance(self._active_tool, wx.Sizer): self._active_tool.ShowItems(show=True) self._active_tool.enable() + self._active_tool.set_state(state) self.canvas.reset_bound_events() self.canvas.Layout() @@ -123,6 +125,11 @@ def __init__(self, canvas: "EditCanvas"): self._sizer = wx.BoxSizer(wx.HORIZONTAL) self.SetSizer(self._sizer) + # Touch controls toggles + self._touch_controls_checkbox = None + self._mouse_mode_checkbox = None + self._build_touch_toggles() + def register_tool(self, name: str): button = wx.Button(self, label=name) self._sizer.Add(button) @@ -133,3 +140,29 @@ def register_tool(self, name: str): wx.EVT_BUTTON, lambda evt: wx.PostEvent(self.canvas, ToolChangeEvent(tool=name)), ) + + def _build_touch_toggles(self): + """Touch control toggles are now in the top toolbar (FilePanel).""" + # Touch controls toggles moved to top toolbar + self._touch_controls_checkbox = None + self._mouse_mode_checkbox = None + + def _on_touch_controls_toggle(self, evt): + """Touch control toggles are now in the top toolbar (FilePanel).""" + evt.Skip() + + def _on_mouse_mode_toggle(self, evt): + """Touch control toggles are now in the top toolbar (FilePanel).""" + evt.Skip() + + def _update_toggle_states(self): + """Touch control toggles are now in the top toolbar (FilePanel).""" + pass + + def _update_toggle_visibility(self): + """Touch control toggles are now in the top toolbar (FilePanel).""" + pass + + def update_touch_toggles(self): + """Touch control toggles are now in the top toolbar (FilePanel).""" + pass diff --git a/amulet_map_editor/programs/edit/edit.py b/amulet_map_editor/programs/edit/edit.py index 46b63aa5c..0c1c135b7 100644 --- a/amulet_map_editor/programs/edit/edit.py +++ b/amulet_map_editor/programs/edit/edit.py @@ -1,13 +1,20 @@ -import wx -from typing import TYPE_CHECKING, Optional, Callable +from typing import TYPE_CHECKING, Optional, Generator import webbrowser +import logging +from threading import Thread +import traceback + +import wx + +from amulet.api.data_types import OperationYieldType EDIT_CONFIG_ID = "amulet_edit" -from amulet_map_editor import log, lang +from amulet_map_editor import lang from amulet_map_editor.api.framework.programs import BaseProgram from amulet_map_editor.api.datatypes import MenuData from amulet_map_editor.api.wx.util.key_config import KeyConfigDialog +from amulet_map_editor.api.wx.ui.traceback_dialog import TracebackDialog from amulet_map_editor.api.wx.ui.simple import SimpleDialog from amulet_map_editor.programs.edit.api.canvas.edit_canvas import EditCanvas from amulet_map_editor.programs.edit.api.key_config import ( @@ -20,9 +27,23 @@ if TYPE_CHECKING: from amulet.api.level import World +log = logging.getLogger(__name__) + class EditExtension(wx.Panel, BaseProgram): - def __init__(self, parent, world: "World", close_self_callback: Callable[[], None]): + # UI elements + _sizer: wx.BoxSizer + # these only exists on setup. Once setup is finished they will be None + _temp_msg: Optional[wx.StaticText] + _temp_loading_bar: Optional[wx.Gauge] + + _world: "World" + _canvas: Optional[EditCanvas] + + # setup is run in a different thread to avoid blocking the UI + _setup_thread: Optional[Thread] + + def __init__(self, parent, world: "World"): wx.Panel.__init__(self, parent) self._sizer = wx.BoxSizer(wx.VERTICAL) self.SetBackgroundColour( @@ -30,14 +51,14 @@ def __init__(self, parent, world: "World", close_self_callback: Callable[[], Non ) self.SetSizer(self._sizer) self._world = world - self._canvas: Optional[EditCanvas] = None - self._close_self_callback = close_self_callback + self._canvas = None + self._setup_thread = None self._sizer.AddStretchSpacer(1) self._temp_msg = wx.StaticText( self, label=lang.get("program_3d_edit.canvas.please_wait") ) - self._temp_msg.SetFont(wx.Font(40, wx.DECORATIVE, wx.NORMAL, wx.NORMAL)) + self._temp_msg.SetFont(wx.Font(40, wx.DEFAULT, wx.NORMAL, wx.NORMAL)) self._sizer.Add(self._temp_msg, 0, flag=wx.ALIGN_CENTER_HORIZONTAL) self._temp_loading_bar = wx.Gauge(self, range=10000) self._sizer.Add(self._temp_loading_bar, 0, flag=wx.EXPAND) @@ -45,23 +66,57 @@ def __init__(self, parent, world: "World", close_self_callback: Callable[[], Non def enable(self): if self._canvas is None: - self.Update() - - self._canvas = EditCanvas(self, self._world, self._close_self_callback) - for arg in self._canvas.setup(): - if isinstance(arg, (int, float)): - self._temp_loading_bar.SetValue(int(min(arg, 1) * 10000)) - elif ( - isinstance(arg, tuple) - and isinstance(arg[0], (int, float)) - and isinstance(arg[1], str) - ): - self._temp_loading_bar.SetValue(int(min(arg[0], 1) * 10000)) - self._temp_msg.SetLabel(arg[1]) - self.Layout() - self.Update() - wx.Yield() + self._canvas = EditCanvas(self, self._world) + self._canvas.Hide() + self._setup_thread = Thread(target=self._thread_setup) + self._setup_thread.start() + else: + self._canvas.enable() + + def _update_loading(self, it: Generator[OperationYieldType, None, None]): + for arg in it: + if isinstance(arg, (int, float)): + self._temp_loading_bar.SetValue(int(min(arg, 1) * 10000)) + elif ( + isinstance(arg, tuple) + and isinstance(arg[0], (int, float)) + and isinstance(arg[1], str) + ): + self._temp_loading_bar.SetValue(int(min(arg[0], 1) * 10000)) + self._temp_msg.SetLabel(arg[1]) + self.Layout() + + def _display_error(self, msg, tb): + dialog = TracebackDialog( + self, + "Exception while setting up canvas", + msg, + tb, + ) + dialog.ShowModal() + dialog.Destroy() + self.Destroy() + + def _thread_setup(self): + """ + Setup and enable all the UI elements. + This can take a while to run so should be done in a new thread. + Everything in here must be thread safe. + """ + try: + self._update_loading(self._canvas.thread_setup()) + except Exception as e: + wx.CallAfter(self._display_error, str(e), traceback.format_exc()) + raise e + else: + wx.CallAfter(self._post_thread_setup) + def _post_thread_setup(self): + """ + Run any setup that is not thread safe. + """ + try: + self._update_loading(self._canvas.post_thread_setup()) edit_config: dict = config.get(EDIT_CONFIG_ID, {}) self._canvas.camera.perspective_fov = edit_config.get("options", {}).get( "fov", 70.0 @@ -75,13 +130,23 @@ def enable(self): "camera_sensitivity", 2.0 ) + self._temp_msg = None + self._temp_loading_bar = None self._sizer.Clear(True) self._sizer.Add(self._canvas, 1, wx.EXPAND) self._canvas.Show() - + self._canvas._set_size() self.Layout() - self._canvas.Update() - self._canvas.enable() + wx.CallAfter( + self._canvas.enable + ) # This must be called after the show handler is run + self._setup_thread = None + except Exception as e: + wx.CallAfter(self._display_error, str(e), traceback.format_exc()) + raise e + + def can_disable(self) -> bool: + return self._setup_thread is None def disable(self): if self._canvas is not None: @@ -92,17 +157,18 @@ def close(self): if self._canvas is not None: self._canvas.close() - def is_closeable(self) -> bool: + def can_close(self) -> bool: """ Check if it is safe to close the UI. :return: True if the program can be closed, False otherwise """ - if self._canvas is None: - # if the edit program has never been opened then it can be closed + if self._setup_thread is not None: + return False + elif self._canvas is None: return True + elif self._canvas.is_closeable(): + return self._check_close_world() else: - if self._canvas.is_closeable(): - return self._check_close_world() log.info( f"The canvas in edit for world {self._world.level_wrapper.level_name} was not closeable for some reason." ) @@ -203,16 +269,29 @@ def _edit_controls(self): edit_config = config.get(EDIT_CONFIG_ID, {}) keybind_id = edit_config.get("keybind_group", DefaultKeybindGroupId) user_keybinds = edit_config.get("user_keybinds", {}) + touch_enabled = bool(edit_config.get("options", {}).get("touch_controls", False)) key_config = KeyConfigDialog( - self, keybind_id, KeybindKeys, PresetKeybinds, user_keybinds + self, keybind_id, KeybindKeys, PresetKeybinds, user_keybinds, + touch_controls_enabled=touch_enabled, ) if key_config.ShowModal() == wx.ID_OK: - user_keybinds, keybind_id, keybinds = key_config.options + user_keybinds, keybind_id, keybinds, touch_controls_enabled = key_config.options edit_config["user_keybinds"] = user_keybinds edit_config["keybind_group"] = keybind_id + edit_config.setdefault("options", {}) + edit_config["options"]["touch_controls"] = bool(touch_controls_enabled) config.put(EDIT_CONFIG_ID, edit_config) self._canvas.buttons.clear_registered_actions() self._canvas.buttons.register_actions(keybinds) + if hasattr(self._canvas, "set_touchscreen_mode"): + # Global touchscreen mode controls toolbar visibility and enables on-screen buttons feature + self._canvas.set_touchscreen_mode(bool(touch_controls_enabled)) + if hasattr(self._canvas, "set_touch_controls_enabled"): + # Preserve the user's previous per-session visibility when enabling mode; default to mode state + self._canvas.set_touch_controls_enabled(bool(touch_controls_enabled)) + # Update the toggle states in the top toolbar (FilePanel) + if hasattr(self._canvas, '_file_panel'): + self._canvas._file_panel.update_touch_toggles() def _edit_options(self): if self._canvas is not None: @@ -230,7 +309,7 @@ def set_fov(evt): fov_ui.Bind(wx.EVT_SPINCTRLDOUBLE, set_fov) sizer.Add( - wx.StaticText(dialog, label="Field of View"), + wx.StaticText(dialog, label=lang.get("program_3d_edit.options.field_of_view")), flag=wx.LEFT | wx.TOP | wx.ALIGN_CENTER_VERTICAL | wx.EXPAND, border=5, ) @@ -241,7 +320,7 @@ def set_fov(evt): ) render_distance_ui = wx.SpinCtrl( - dialog, min=0, max=50, initial=render_distance + dialog, min=0, max=500, initial=render_distance ) def set_render_distance(evt): @@ -249,7 +328,7 @@ def set_render_distance(evt): render_distance_ui.Bind(wx.EVT_SPINCTRL, set_render_distance) sizer.Add( - wx.StaticText(dialog, label="Render Distance"), + wx.StaticText(dialog, label=lang.get("program_3d_edit.options.render_distance")), flag=wx.LEFT | wx.TOP | wx.ALIGN_CENTER_VERTICAL | wx.EXPAND, border=5, ) @@ -260,7 +339,7 @@ def set_render_distance(evt): ) camera_sensitivity_ui = wx.SpinCtrlDouble( - dialog, min=0, max=10, initial=camera_sensitivity + dialog, min=0.1, max=10, initial=camera_sensitivity, inc=0.1 ) def set_camera_sensitivity(evt): @@ -268,7 +347,7 @@ def set_camera_sensitivity(evt): camera_sensitivity_ui.Bind(wx.EVT_SPINCTRLDOUBLE, set_camera_sensitivity) sizer.Add( - wx.StaticText(dialog, label="Camera Sensitivity"), + wx.StaticText(dialog, label=lang.get("program_3d_edit.options.camera_sensitivity")), flag=wx.LEFT | wx.TOP | wx.ALIGN_CENTER_VERTICAL | wx.EXPAND, border=5, )