class UI: # user-configured UI scale factor scale = 1.0 max_onion_alpha = 0.5 charset_name = 'ui' palette_name = 'c64_original' # red color for warnings error_color_index = UIColors.brightred # low-contrast background texture that distinguishes UI from flat color grain_texture_path = UI_ASSET_DIR + 'bgnoise_alpha.png' # expose to classes that don't want to import this module asset_dir = UI_ASSET_DIR visible = True logg = False popup_hold_to_show = False flip_affects_xforms = True tool_classes = [ PencilTool, EraseTool, GrabTool, RotateTool, TextTool, SelectTool, PasteTool ] tool_selected_log = 'tool selected' art_selected_log = 'Now editing' frame_selected_log = 'Now editing frame %s (hold time %ss)' layer_selected_log = 'Now editing layer: %s' swap_color_log = 'Swapped FG/BG colors' affects_char_on_log = 'will affect characters' affects_char_off_log = 'will not affect characters' affects_fg_on_log = 'will affect foreground colors' affects_fg_off_log = 'will not affect foreground colors' affects_bg_on_log = 'will affect background colors' affects_bg_off_log = 'will not affect background colors' affects_xform_on_log = 'will affect character rotation/flip' affects_xform_off_log = 'will not affect character rotation/flip' xform_selected_log = 'Selected character transform:' show_edit_ui_log = 'Edit UI hidden, press %s to unhide.' def __init__(self, app, active_art): self.app = app # the current art being edited self.active_art = active_art # dialog box set here self.active_dialog = None # keyboard-navigabnle element with current focus self.keyboard_focus_element = None # easy color index lookups self.colors = UIColors() # for UI, view /and/ projection matrix are identity # (aspect correction is done in set_scale) self.view_matrix = np.eye(4, 4, dtype=np.float32) self.charset = self.app.load_charset(self.charset_name, False) self.palette = self.app.load_palette(self.palette_name, False) # currently selected char, fg color, bg color, xform - from art self.selected_char = self.active_art.selected_char self.selected_fg_color = self.active_art.selected_fg_color self.selected_bg_color = self.active_art.selected_bg_color self.selected_xform = self.active_art.selected_xform self.selected_tool, self.previous_tool = None, None # set True when tool settings change, cleared after update, used by # cursor to determine if cursor update needed self.tool_settings_changed = False self.tools = [] # create tools for t in self.tool_classes: new_tool = t(self) tool_name = '%s_tool' % new_tool.name setattr(self, tool_name, new_tool) # stick in a list for popup tool tab self.tools.append(new_tool) self.selected_tool = self.pencil_tool # clipboard: list of EditCommandTiles, set by cut/copy, used by paste self.clipboard = [] # track clipboard contents' size so we don't have to recompute it every # cursor preview update self.clipboard_width = 0 self.clipboard_height = 0 # create elements self.elements = [] self.hovered_elements = [] # set geo sizes, force scale update self.set_scale(self.scale) self.fps_counter = FPSCounterUI(self) self.console = ConsoleUI(self) self.status_bar = StatusBarUI(self) self.popup = ToolPopup(self) self.message_line = MessageLineUI(self) self.debug_text = DebugTextUI(self) self.pulldown = PulldownMenu(self) self.menu_bar = None self.art_menu_bar = ArtMenuBar(self) self.game_menu_bar = GameMenuBar(self) self.menu_bar = self.art_menu_bar self.edit_list_panel = EditListPanel(self) self.edit_object_panel = EditObjectPanel(self) self.game_selection_label = GameSelectionLabel(self) self.game_hover_label = GameHoverLabel(self) self.elements += [ self.fps_counter, self.status_bar, self.popup, self.message_line, self.debug_text, self.pulldown, self.art_menu_bar, self.game_menu_bar, self.edit_list_panel, self.edit_object_panel, self.game_hover_label, self.game_selection_label ] # add console last so it draws last self.elements.append(self.console) # grain texture img = Image.open(self.grain_texture_path) img = img.convert('RGBA') width, height = img.size self.grain_texture = Texture(img.tobytes(), width, height) self.grain_texture.set_wrap(True) self.grain_texture.set_filter(GL.GL_LINEAR, GL.GL_LINEAR_MIPMAP_LINEAR) # update elements that weren't created when UI scale was determined self.set_elements_scale() # if editing is disallowed, hide game mode UI if not self.app.can_edit: self.set_game_edit_ui_visibility(False) def set_scale(self, new_scale): old_scale = self.scale self.scale = new_scale # update UI renderable geo sizes for new scale # determine width and height of current window in chars # use floats, window might be a fractional # of chars wide/tall aspect = float(self.app.window_width) / self.app.window_height inv_aspect = float(self.app.window_height) / self.app.window_width # MAYBE-TODO: this math is correct but hard to follow, rewrite for clarity width = self.app.window_width / (self.charset.char_width * self.scale * inv_aspect) height = self.app.window_height / (self.charset.char_height * self.scale * inv_aspect) # any new UI elements created should use new scale UIArt.quad_width = 2 / width * aspect UIArt.quad_height = 2 / height * aspect self.width_tiles = width * (inv_aspect / self.scale) self.height_tiles = height / self.scale # tell elements to refresh self.set_elements_scale() if self.scale != old_scale: self.message_line.post_line( 'UI scale is now %s (%.3f x %.3f)' % (self.scale, self.width_tiles, self.height_tiles)) def set_elements_scale(self): for e in self.elements: e.art.quad_width, e.art.quad_height = UIArt.quad_width, UIArt.quad_height # Art dimensions may well need to change e.reset_art() e.reset_loc() e.art.geo_changed = True def window_resized(self): # recalc renderables' quad size (same scale, different aspect) self.set_scale(self.scale) def set_active_art(self, new_art): self.active_art = new_art new_charset = self.active_art.charset new_palette = self.active_art.palette # make sure selection isn't out of bounds in new art old_selection = self.select_tool.selected_tiles.copy() for tile in old_selection: x, y = tile[0], tile[1] if x >= new_art.width or y >= new_art.height: self.select_tool.selected_tiles.pop(tile, None) # keep cursor in bounds self.app.cursor.clamp_to_active_art() # set camera bounds based on art size self.app.camera.set_for_art(new_art) # set for popup self.popup.set_active_charset(new_charset) self.popup.set_active_palette(new_palette) # if popup up eg toggled, redraw it completely if self.popup.visible: self.popup.reset_art() self.popup.reset_loc() # set to art's selected tile attributes self.selected_char = self.active_art.selected_char self.selected_fg_color = self.active_art.selected_fg_color self.selected_bg_color = self.active_art.selected_bg_color self.selected_xform = self.active_art.selected_xform self.reset_onion_frames() self.reset_edit_renderables() # now that renderables are moved, rescale/reposition grid self.app.grid.reset() # tell select tool renderables for r in [ self.select_tool.select_renderable, self.select_tool.drag_renderable ]: r.quad_size_ref = new_art r.rebuild_geo(self.select_tool.selected_tiles) self.app.update_window_title() if self.app.can_edit: self.message_line.post_line( '%s %s' % (self.art_selected_log, self.active_art.filename)) def set_active_art_by_filename(self, art_filename): for i, art in enumerate(self.app.art_loaded_for_edit): if art_filename == art.filename: break new_active_art = self.app.art_loaded_for_edit.pop(i) self.app.art_loaded_for_edit.insert(0, new_active_art) new_active_renderable = self.app.edit_renderables.pop(i) self.app.edit_renderables.insert(0, new_active_renderable) self.set_active_art(new_active_art) def previous_active_art(self): "cycles to next art in app.art_loaded_for_edit" if len(self.app.art_loaded_for_edit) == 1: return next_active_art = self.app.art_loaded_for_edit.pop(-1) self.app.art_loaded_for_edit.insert(0, next_active_art) next_active_renderable = self.app.edit_renderables.pop(-1) self.app.edit_renderables.insert(0, next_active_renderable) self.set_active_art(self.app.art_loaded_for_edit[0]) def next_active_art(self): if len(self.app.art_loaded_for_edit) == 1: return last_active_art = self.app.art_loaded_for_edit.pop(0) self.app.art_loaded_for_edit.append(last_active_art) last_active_renderable = self.app.edit_renderables.pop(0) self.app.edit_renderables.append(last_active_renderable) self.set_active_art(self.app.art_loaded_for_edit[0]) def set_selected_tool(self, new_tool): if self.app.game_mode: return if new_tool == self.selected_tool: return # bail out of text entry if active if self.selected_tool is self.text_tool: self.text_tool.finish_entry() self.previous_tool = self.selected_tool self.selected_tool = new_tool self.popup.reset_art() self.tool_settings_changed = True # close menu if we selected tool from it if self.menu_bar.active_menu_name: self.menu_bar.close_active_menu() self.message_line.post_line( '%s %s' % (self.selected_tool.button_caption, self.tool_selected_log)) def get_longest_tool_name_length(self): "VERY specific function to help status bar draw its buttons" longest = 0 for tool in self.tools: if len(tool.button_caption) > longest: longest = len(tool.button_caption) return longest def cycle_selected_tool(self, back=False): if not self.active_art: return tool_index = self.tools.index(self.selected_tool) if back: tool_index -= 1 else: tool_index += 1 tool_index %= len(self.tools) self.set_selected_tool(self.tools[tool_index]) def set_selected_xform(self, new_xform): self.selected_xform = new_xform self.popup.set_xform(new_xform) self.tool_settings_changed = True line = '%s %s' % (self.xform_selected_log, uv_names[self.selected_xform]) self.message_line.post_line(line) def cycle_selected_xform(self, back=False): if self.app.game_mode: return xform = self.selected_xform if back: xform -= 1 else: xform += 1 xform %= UV_FLIP270 + 1 self.set_selected_xform(xform) def reset_onion_frames(self, new_art=None): "set correct visibility, frame, and alpha for all onion renderables" new_art = new_art or self.active_art alpha = self.max_onion_alpha total_onion_frames = 0 def set_onion(r, new_frame, alpha): # scale back if fewer than MAX_ONION_FRAMES in either direction if total_onion_frames >= new_art.frames: r.visible = False return r.visible = True if not new_art is r.art: r.set_art(new_art) r.set_frame(new_frame) r.alpha = alpha # make BG dimmer so it's easier to see r.bg_alpha = alpha / 2 # populate "next" frames first for i, r in enumerate(self.app.onion_renderables_next): total_onion_frames += 1 new_frame = new_art.active_frame + i + 1 set_onion(r, new_frame, alpha) alpha /= 2 #print('next onion %s set to frame %s alpha %s' % (i, new_frame, alpha)) alpha = self.max_onion_alpha for i, r in enumerate(self.app.onion_renderables_prev): total_onion_frames += 1 new_frame = new_art.active_frame - (i + 1) set_onion(r, new_frame, alpha) # each successive onion layer is dimmer alpha /= 2 #print('previous onion %s set to frame %s alpha %s' % (i, new_frame, alpha)) def set_active_frame(self, new_frame): if not self.active_art.set_active_frame(new_frame): return self.reset_onion_frames() self.tool_settings_changed = True frame = self.active_art.active_frame delay = self.active_art.frame_delays[frame] if self.app.can_edit: self.message_line.post_line(self.frame_selected_log % (frame + 1, delay)) def set_active_layer(self, new_layer): self.active_art.set_active_layer(new_layer) z = self.active_art.layers_z[self.active_art.active_layer] self.app.grid.z = z self.select_tool.select_renderable.z = z self.select_tool.drag_renderable.z = z self.app.cursor.z = z self.app.update_window_title() self.tool_settings_changed = True layer_name = self.active_art.layer_names[self.active_art.active_layer] if self.app.can_edit: self.message_line.post_line(self.layer_selected_log % layer_name) def select_char(self, new_char_index): if not self.active_art: return # wrap at last valid index self.selected_char = new_char_index % self.active_art.charset.last_index self.tool_settings_changed = True def select_color(self, new_color_index, fg): "common code for select_fg/bg" if not self.active_art: return new_color_index %= len(self.active_art.palette.colors) if fg: self.selected_fg_color = new_color_index else: self.selected_bg_color = new_color_index self.tool_settings_changed = True def select_fg(self, new_fg_index): self.select_color(new_fg_index, True) def select_bg(self, new_bg_index): self.select_color(new_bg_index, False) def swap_fg_bg_colors(self): if self.app.game_mode: return fg, bg = self.selected_fg_color, self.selected_bg_color self.selected_fg_color, self.selected_bg_color = bg, fg self.tool_settings_changed = True self.message_line.post_line(self.swap_color_log) def cut_selection(self): self.copy_selection() self.erase_tiles_in_selection() def erase_selection_or_art(self): if len(self.select_tool.selected_tiles) > 0: self.erase_tiles_in_selection() return self.select_all() self.erase_tiles_in_selection() self.select_none() def erase_tiles_in_selection(self): # create and commit command group to clear all tiles in selection frame, layer = self.active_art.active_frame, self.active_art.active_layer new_command = EditCommand(self.active_art) for tile in self.select_tool.selected_tiles: new_tile_command = EditCommandTile(self.active_art) new_tile_command.set_tile(frame, layer, *tile) b_char, b_fg, b_bg, b_xform = self.active_art.get_tile_at( frame, layer, *tile) new_tile_command.set_before(b_char, b_fg, b_bg, b_xform) a_char = a_fg = 0 a_xform = UV_NORMAL # clear to current BG a_bg = self.selected_bg_color new_tile_command.set_after(a_char, a_fg, a_bg, a_xform) new_command.add_command_tiles([new_tile_command]) new_command.apply() self.active_art.command_stack.commit_commands([new_command]) self.active_art.set_unsaved_changes(True) def copy_selection(self): # convert current selection tiles (active frame+layer) into # EditCommandTiles for Cursor.preview_edits # (via PasteTool get_paint_commands) self.clipboard = [] frame, layer = self.active_art.active_frame, self.active_art.active_layer min_x, min_y = 9999, 9999 max_x, max_y = -1, -1 for tile in self.select_tool.selected_tiles: x, y = tile[0], tile[1] if x < min_x: min_x = x elif x > max_x: max_x = x if y < min_y: min_y = y elif y > max_y: max_y = y art = self.active_art new_tile_command = EditCommandTile(art) new_tile_command.set_tile(frame, layer, x, y) a_char, a_fg, a_bg, a_xform = art.get_tile_at(frame, layer, x, y) # set data as "after" state, before will be set by cursor hover new_tile_command.set_after(a_char, a_fg, a_bg, a_xform) self.clipboard.append(new_tile_command) # rebase tiles at top left corner of clipboard tiles for tile_command in self.clipboard: x = tile_command.x - min_x y = tile_command.y - min_y tile_command.set_tile(frame, layer, x, y) self.clipboard_width = max_x - min_x self.clipboard_height = max_y - min_y def crop_to_selection(self, art): # ignore non-rectangular selection features, use top left and bottom # right corners if len(self.select_tool.selected_tiles) == 0: return min_x, max_x = 99999, -1 min_y, max_y = 99999, -1 for tile in self.select_tool.selected_tiles: x, y = tile[0], tile[1] if x < min_x: min_x = x elif x > max_x: max_x = x if y < min_y: min_y = y elif y > max_y: max_y = y w = max_x - min_x + 1 h = max_y - min_y + 1 # create command for undo/redo command = EntireArtCommand(art, min_x, min_y) command.save_tiles(before=True) art.resize(w, h, min_x, min_y) self.app.log('Resized %s to %s x %s' % (art.filename, w, h)) art.set_unsaved_changes(True) # clear selection to avoid having tiles we know are OoB selected self.select_tool.selected_tiles = {} self.adjust_for_art_resize(art) # commit command command.save_tiles(before=False) art.command_stack.commit_commands([command]) def reset_edit_renderables(self): # reposition all art renderables and change their opacity x, y = 0, 0 for i, r in enumerate(self.app.edit_renderables): # always put active art at 0,0 if r in self.active_art.renderables: r.alpha = 1 # if game mode, don't lerp if self.app.game_mode: r.snap_to(0, 0, 0) else: r.move_to(0, 0, 0, 0.2) else: r.alpha = 0.5 if self.app.game_mode: # shift arts progressively further back r.snap_to(x, y, -i) else: r.move_to(x * MDI_MARGIN, 0, -i, 0.2) x += r.art.width * r.art.quad_width y -= r.art.height * r.art.quad_height def adjust_for_art_resize(self, art): if art is not self.active_art: return # update grid, camera, cursor self.app.camera.set_for_art(art) self.app.camera.toggle_zoom_extents(override=True) self.reset_edit_renderables() self.app.grid.reset() if self.app.cursor.x > art.width: self.app.cursor.x = art.width if self.app.cursor.y > art.height: self.app.cursor.y = art.height self.app.cursor.moved = True def resize_art(self, art, new_width, new_height, origin_x, origin_y, bg_fill): # create command for undo/redo command = EntireArtCommand(art, origin_x, origin_y) command.save_tiles(before=True) # resize art.resize(new_width, new_height, origin_x, origin_y, bg_fill) self.adjust_for_art_resize(art) # commit command command.save_tiles(before=False) art.command_stack.commit_commands([command]) art.set_unsaved_changes(True) def select_none(self): self.select_tool.selected_tiles = {} def select_all(self): self.select_tool.selected_tiles = {} for y in range(self.active_art.height): for x in range(self.active_art.width): self.select_tool.selected_tiles[(x, y)] = True def invert_selection(self): old_selection = self.select_tool.selected_tiles.copy() self.select_tool.selected_tiles = {} for y in range(self.active_art.height): for x in range(self.active_art.width): if not old_selection.get((x, y), False): self.select_tool.selected_tiles[(x, y)] = True def get_screen_coords(self, window_x, window_y): x = (2 * window_x) / self.app.window_width - 1 y = (-2 * window_y) / self.app.window_height + 1 return x, y def update(self): self.select_tool.update() # window coordinates -> OpenGL coordinates mx, my = self.get_screen_coords(self.app.mouse_x, self.app.mouse_y) # test elements for hover was_hovering = self.hovered_elements[:] self.hovered_elements = [] for e in self.elements: # don't hover anything while console is up if self.console.visible: continue # only check visible elements if self.app.has_mouse_focus and e.is_visible( ) and e.can_hover and e.is_inside(mx, my): self.hovered_elements.append(e) # only hover if we weren't last update if not e in was_hovering: e.hovered() for e in was_hovering: # unhover if app window loses mouse focus if not self.app.has_mouse_focus or not e in self.hovered_elements: e.unhovered() # update all elements, regardless of whether they're being hovered etc for e in self.elements: # don't update invisible items if e.is_visible() or e.update_when_invisible: e.update() # art update: tell renderables to refresh buffers e.art.update() self.tool_settings_changed = False def clicked(self, mouse_button): handled = False # return True if any button handled the input for e in self.hovered_elements: if not e.is_visible(): continue if e.clicked(mouse_button): handled = True # close pulldown if clicking outside it / the menu bar if self.pulldown.visible and not self.pulldown in self.hovered_elements and not self.menu_bar in self.hovered_elements: self.menu_bar.close_active_menu() return handled def unclicked(self, mouse_button): handled = False for e in self.hovered_elements: if e.unclicked(mouse_button): handled = True return handled def wheel_moved(self, wheel_y): handled = False # use wheel to scroll chooser dialogs # TODO: look up "up arrow" bind instead? how to get # an SDL keycode from that? if self.active_dialog: keycode = sdl2.SDLK_UP if wheel_y > 0 else sdl2.SDLK_DOWN self.active_dialog.handle_input(keycode, self.app.il.shift_pressed, self.app.il.alt_pressed, self.app.il.ctrl_pressed) handled = True elif len(self.hovered_elements) > 0: for e in self.hovered_elements: if e.wheel_moved(wheel_y): handled = True return handled def quick_grab(self): if self.app.game_mode: return if self.console.visible or self.popup in self.hovered_elements: return self.grab_tool.grab() self.tool_settings_changed = True def undo(self): # if still painting, finish if self.app.cursor.current_command: self.app.cursor.finish_paint() self.active_art.command_stack.undo() self.active_art.set_unsaved_changes(True) def redo(self): self.active_art.command_stack.redo() def open_dialog(self, dialog_class, options={}): if self.app.game_mode and not dialog_class.game_mode_visible: return dialog = dialog_class(self, options) self.active_dialog = dialog self.keyboard_focus_element = self.active_dialog # insert dialog at index 0 so it draws first instead of last #self.elements.insert(0, dialog) self.elements.remove(self.console) self.elements.append(dialog) self.elements.append(self.console) def is_game_edit_ui_visible(self): return self.game_menu_bar.visible def set_game_edit_ui_visibility(self, visible, show_message=True): self.game_menu_bar.visible = visible self.edit_list_panel.visible = visible self.edit_object_panel.visible = visible if not visible: # relinquish keyboard focus in play mode self.keyboard_focus_element = None if show_message and self.app.il: bind = self.app.il.get_command_shortcut('toggle_game_edit_ui') bind = bind.title() self.message_line.post_line(self.show_edit_ui_log % bind, 10) else: self.message_line.post_line('') self.app.update_window_title() def object_selection_changed(self): if len(self.app.gw.selected_objects) == 0: self.keyboard_focus_element = None self.refocus_keyboard() def switch_edit_panel_focus(self, reverse=False): # only allow tabbing away if list panel is in allowed mode lp = self.edit_list_panel if self.keyboard_focus_element is lp and \ lp.list_operation in lp.list_operations_allow_kb_focus and \ self.active_dialog: self.keyboard_focus_element = self.active_dialog # prevent any other tabbing away from active dialog if self.active_dialog: return # cycle keyboard focus between possible panels focus_elements = [None] if self.edit_list_panel.is_visible(): focus_elements.append(self.edit_list_panel) if self.edit_object_panel.is_visible(): focus_elements.append(self.edit_object_panel) if len(focus_elements) == 1: return focus_elements.append(None) # handle shift-tab if reverse: focus_elements.reverse() for i, element in enumerate(focus_elements[:-1]): if self.keyboard_focus_element is element: self.keyboard_focus_element = focus_elements[i + 1] break # update keyboard hover for both self.edit_object_panel.update_keyboard_hover() self.edit_list_panel.update_keyboard_hover() def refocus_keyboard(self): "called when an element closes, sets new keyboard_focus_element" if self.active_dialog: self.keyboard_focus_element = self.active_dialog if self.keyboard_focus_element: return if self.popup.visible: self.keyboard_focus_element = self.popup elif self.pulldown.visible: self.keyboard_focus_element = self.pulldown elif self.edit_list_panel.is_visible( ) and not self.edit_object_panel.is_visible(): self.keyboard_focus_element = self.edit_list_panel elif self.edit_object_panel.is_visible( ) and not self.edit_list_panel.is_visible(): self.keyboard_focus_element = self.edit_object_panel def keyboard_navigate(self, move_x, move_y): self.keyboard_focus_element.keyboard_navigate(move_x, move_y) def toggle_game_edit_ui(self): # if editing is disallowed, only run this once to disable UI if not self.app.can_edit: return elif not self.app.game_mode: return self.set_game_edit_ui_visibility(not self.game_menu_bar.visible) def destroy(self): for e in self.elements: e.destroy() self.grain_texture.destroy() def render(self): for e in self.elements: if e.is_visible(): e.render()