playscii/ui_tool.py

592 lines
23 KiB
Python

import sdl2
from PIL import Image
from art import (
UV_FLIP90,
UV_FLIP270,
UV_FLIPX,
UV_FLIPY,
UV_NORMAL,
UV_ROTATE90,
UV_ROTATE180,
UV_ROTATE270,
)
from edit_command import EditCommandTile
from key_shifts import SHIFT_MAP
from selection import SelectionRenderable
from texture import Texture
class UITool:
name = "DEBUGTESTTOOL"
# name visible in popup's tool tab
button_caption = "Debug Tool"
# paint continuously, ie every time mouse enters a new tile
paint_while_dragging = True
# show preview of paint result under cursor
show_preview = True
# if True, refresh paint preview immediately after Cursor.finish_paint
# set this for anything that produces a different change each paint
update_preview_after_paint = False
brush_size = 1
# affects char/fg/bg/xform masks are relevant to how this tool works
# (false for eg Selection tool)
affects_masks = True
# filename of icon in UI_ASSET_DIR, shown on cursor
icon_filename = "icon.png"
def __init__(self, ui):
self.ui = ui
self.affects_char = True
self.affects_fg_color = True
self.affects_bg_color = True
self.affects_xform = True
# load icon, cursor's sprite renderable will reference this texture
icon_filename = self.ui.asset_dir + self.icon_filename
self.icon_texture = self.load_icon_texture(icon_filename)
def load_icon_texture(self, img_filename):
img = Image.open(img_filename)
img = img.convert("RGBA")
img = img.transpose(Image.FLIP_TOP_BOTTOM)
return Texture(img.tobytes(), *img.size)
def get_icon_texture(self):
"""
Returns icon texture that should display for tool's current state.
(override to eg choose from multiples for mod keys)
"""
return self.icon_texture
def get_button_caption(self):
# normally just returns button_caption, but can be overridden to
# provide custom behavior (eg fill tool)
return self.button_caption
def toggle_affects_char(self):
if not self.affects_masks or self.ui.app.game_mode:
return
self.affects_char = not self.affects_char
self.ui.tool_settings_changed = True
line = self.button_caption + " "
line = "{} {}".format(
self.button_caption,
[self.ui.affects_char_off_log, self.ui.affects_char_on_log][
self.affects_char
],
)
self.ui.message_line.post_line(line)
def toggle_affects_fg(self):
if not self.affects_masks or self.ui.app.game_mode:
return
self.affects_fg_color = not self.affects_fg_color
self.ui.tool_settings_changed = True
line = "{} {}".format(
self.button_caption,
[self.ui.affects_fg_off_log, self.ui.affects_fg_on_log][
self.affects_fg_color
],
)
self.ui.message_line.post_line(line)
def toggle_affects_bg(self):
if not self.affects_masks or self.ui.app.game_mode:
return
self.affects_bg_color = not self.affects_bg_color
self.ui.tool_settings_changed = True
line = "{} {}".format(
self.button_caption,
[self.ui.affects_bg_off_log, self.ui.affects_bg_on_log][
self.affects_bg_color
],
)
self.ui.message_line.post_line(line)
def toggle_affects_xform(self):
if not self.affects_masks or self.ui.app.game_mode:
return
self.affects_xform = not self.affects_xform
self.ui.tool_settings_changed = True
line = "{} {}".format(
self.button_caption,
[self.ui.affects_xform_off_log, self.ui.affects_xform_on_log][
self.affects_xform
],
)
self.ui.message_line.post_line(line)
def get_paint_commands(self):
"returns a list of EditCommandTiles for a given paint operation"
return []
def increase_brush_size(self):
if not self.brush_size:
return
self.brush_size += 1
self.ui.app.cursor.set_scale(self.brush_size)
self.ui.tool_settings_changed = True
def decrease_brush_size(self):
if not self.brush_size:
return
if self.brush_size > 1:
self.brush_size -= 1
self.ui.app.cursor.set_scale(self.brush_size)
self.ui.tool_settings_changed = True
class PencilTool(UITool):
name = "pencil"
# "Paint" not Pencil so the A mnemonic works :/
button_caption = "Paint"
icon_filename = "tool_paint.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
"""
return the tile value changes this tool would perform on a tile -
lets Pencil and Erase tools use same paint()
"""
a_char = self.ui.selected_char if self.affects_char else None
# don't paint fg color for blank characters
# (disabled, see BB issue #86)
# a_fg = self.ui.selected_fg_color if self.affects_fg_color and a_char != 0 else None
a_fg = self.ui.selected_fg_color if self.affects_fg_color else None
a_bg = self.ui.selected_bg_color if self.affects_bg_color else None
a_xform = self.ui.selected_xform if self.affects_xform else None
return a_char, a_fg, a_bg, a_xform
def get_paint_commands(self):
commands = []
art = self.ui.active_art
frame = art.active_frame
layer = art.active_layer
cur = self.ui.app.cursor
# handle dragging while painting (cursor does the heavy lifting here)
# !!TODO!! finish this, work in progress
if cur.moved_this_frame() and cur.current_command and False: # DEBUG
# print('%s: cursor moved' % self.ui.app.get_elapsed_time()) #DEBUG
tiles = cur.get_tiles_under_drag()
else:
tiles = cur.get_tiles_under_brush()
for tile in tiles:
# don't allow painting out of bounds
if not art.is_tile_inside(*tile):
continue
# if a selection is active, only paint inside it
if len(self.ui.select_tool.selected_tiles) > 0:
if not self.ui.select_tool.selected_tiles.get(tile, False):
continue
new_tc = EditCommandTile(art)
new_tc.set_tile(frame, layer, *tile)
b_char, b_fg, b_bg, b_xform = art.get_tile_at(frame, layer, *tile)
new_tc.set_before(b_char, b_fg, b_bg, b_xform)
a_char, a_fg, a_bg, a_xform = self.get_tile_change(
b_char, b_fg, b_bg, b_xform
)
new_tc.set_after(a_char, a_fg, a_bg, a_xform)
# Note: even if command has same result as another in command_tiles,
# add it anyway as it may be a tool for which subsequent edits to
# the same tile have different effects, eg rotate
if not new_tc.is_null():
commands.append(new_tc)
return commands
class EraseTool(PencilTool):
name = "erase"
button_caption = "Erase"
icon_filename = "tool_erase.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
char = 0 if self.affects_char else None
fg = 0 if self.affects_fg_color else None
# erase to BG color, not transparent
bg = self.ui.selected_bg_color if self.affects_bg_color else None
xform = UV_NORMAL if self.affects_xform else None
return char, fg, bg, xform
class RotateTool(PencilTool):
name = "rotate"
button_caption = "Rotate"
update_preview_after_paint = True
rotation_shifts = {
UV_NORMAL: UV_ROTATE90,
UV_ROTATE90: UV_ROTATE180,
UV_ROTATE180: UV_ROTATE270,
UV_ROTATE270: UV_NORMAL,
# support flipped characters! counter-intuitive results though?
UV_FLIPX: UV_FLIP270,
UV_FLIP270: UV_FLIPY,
UV_FLIPY: UV_ROTATE270,
UV_FLIP90: UV_FLIPX,
}
icon_filename = "tool_rotate.png"
def get_tile_change(self, b_char, b_fg, b_bg, b_xform):
return b_char, b_fg, b_bg, self.rotation_shifts[b_xform]
class GrabTool(UITool):
name = "grab"
button_caption = "Grab"
brush_size = None
show_preview = False
icon_filename = "tool_grab.png"
def grab(self):
x, y = self.ui.app.cursor.get_tile()
art = self.ui.active_art
if not art.is_tile_inside(x, y):
return
# in order to get the actual tile under the cursor, we must undo the
# cursor preview edits, grab, then redo them
for edit in self.ui.app.cursor.preview_edits:
edit.undo()
frame, layer = art.active_frame, art.active_layer
if self.affects_char:
self.ui.selected_char = art.get_char_index_at(frame, layer, x, y)
if self.affects_fg_color:
self.ui.selected_fg_color = art.get_fg_color_index_at(frame, layer, x, y)
if self.affects_bg_color:
self.ui.selected_bg_color = art.get_bg_color_index_at(frame, layer, x, y)
if self.affects_xform:
# tells popup etc xform has changed
self.ui.set_selected_xform(art.get_char_transform_at(frame, layer, x, y))
for edit in self.ui.app.cursor.preview_edits:
edit.apply()
class TextTool(UITool):
name = "text"
button_caption = "Text"
brush_size = None
show_preview = False
icon_filename = "tool_text.png"
def __init__(self, ui):
UITool.__init__(self, ui)
self.input_active = False
self.cursor = None
def start_entry(self):
self.cursor = self.ui.app.cursor
# popup gobbles keyboard input, so always dismiss it if it's up
if self.ui.popup.visible:
self.ui.popup.hide()
if (
self.cursor.x < 0
or self.cursor.x > self.ui.active_art.width
or -self.cursor.y < 0
or -self.cursor.y > self.ui.active_art.height
):
return
self.input_active = True
self.reset_cursor_start(self.cursor.x, -self.cursor.y)
self.cursor.start_paint()
# self.ui.message_line.post_line('Started text entry at %s, %s' % (self.start_x + 1, self.start_y + 1))
self.ui.message_line.post_line(
"Started text entry, press Escape to stop entering text.", 5
)
def finish_entry(self):
self.input_active = False
self.ui.tool_settings_changed = True
if self.cursor:
self.cursor.finish_paint()
# self.ui.message_line.post_line('Finished text entry at %s, %s' % (x, y))
self.ui.message_line.post_line("Finished text entry.")
def reset_cursor_start(self, new_x, new_y):
self.start_x, self.start_y = int(new_x), int(new_y)
def handle_keyboard_input(self, key, shift_pressed, ctrl_pressed, alt_pressed):
# for now, do nothing on ctrl/alt
if ctrl_pressed or alt_pressed:
return
# popup should get input if it's up
if self.ui.popup.visible:
return
keystr = sdl2.SDL_GetKeyName(key).decode()
art = self.ui.active_art
frame, layer = art.active_frame, art.active_layer
x, y = int(self.cursor.x), int(-self.cursor.y)
char_w, char_h = art.quad_width, art.quad_height
# TODO: if cursor isn't inside selection, bail early
if keystr == "Return":
if self.cursor.y < art.width:
self.cursor.x = self.start_x
self.cursor.y -= 1
elif keystr == "Backspace":
if self.cursor.x > self.start_x:
self.cursor.x -= char_w
# undo command on previous tile
self.cursor.current_command.undo_commands_for_tile(
frame, layer, x - 1, y
)
elif keystr == "Space":
keystr = " "
elif keystr == "Up":
if -self.cursor.y > 0:
self.cursor.y += 1
elif keystr == "Down":
if -self.cursor.y < art.height - 1:
self.cursor.y -= 1
elif keystr == "Left":
if self.cursor.x > 0:
self.cursor.x -= char_w
elif keystr == "Right":
if self.cursor.x < art.width - 1:
self.cursor.x += char_w
elif keystr == "Escape":
self.finish_entry()
return
# ignore any other non-character keys
if len(keystr) > 1:
return
# respect capslock
# NOTE: if user has their OS rebind capslock to something else, SDL2
# will still track its state as a toggle. which means sometimes these
# users will try to do text entry here and be surprised to see all caps.
# if this becomes an issue, maybe offer a cfg setting to always ignore
# capslock? since it's kind of a power user thing anyway.
if keystr.isalpha() and not shift_pressed and not self.ui.app.il.capslock_on:
keystr = keystr.lower()
elif not keystr.isalpha() and shift_pressed:
keystr = SHIFT_MAP.get(keystr, " ")
# if cursor got out of bounds, don't input
if x < 0 or x >= art.width or y < 0 or y >= art.height:
return
# create tile command
new_tc = EditCommandTile(art)
new_tc.set_tile(frame, layer, x, y)
b_char, b_fg, b_bg, b_xform = art.get_tile_at(frame, layer, x, y)
new_tc.set_before(b_char, b_fg, b_bg, b_xform)
a_char = art.charset.get_char_index(keystr)
a_fg = self.ui.selected_fg_color if self.affects_fg_color else None
a_bg = self.ui.selected_bg_color if self.affects_bg_color else None
a_xform = self.ui.selected_xform if self.affects_xform else None
new_tc.set_after(a_char, a_fg, a_bg, a_xform)
# add command, apply immediately, and move cursor
if self.cursor.current_command:
self.cursor.current_command.add_command_tiles([new_tc])
else:
self.ui.app.log("DEV WARNING: Cursor current command was expected")
new_tc.apply()
self.cursor.x += char_w
if self.cursor.x >= self.ui.active_art.width:
self.cursor.x = self.start_x
self.cursor.y -= char_h
if -self.cursor.y >= self.ui.active_art.height:
self.finish_entry()
class SelectTool(UITool):
name = "select"
button_caption = "Select"
brush_size = None
affects_masks = False
show_preview = False
icon_filename = "tool_select_add.png" # used only for toolbar
icon_filename_normal = "tool_select.png"
icon_filename_add = "tool_select_add.png"
icon_filename_sub = "tool_select_sub.png"
def __init__(self, ui):
UITool.__init__(self, ui)
self.selection_in_progress = False
# dict of all tiles (x, y) that have been selected
# (dict for fast random access in SelectionRenderable.get_adjacet_tile)
self.selected_tiles, self.last_selection = {}, {}
# dict of tiles being selected in a drag that's active right now
self.current_drag, self.last_drag = {}, {}
self.drag_start_x, self.drag_start_y = -1, -1
# create selected tiles and current drag LineRenderables
self.select_renderable = SelectionRenderable(self.ui.app, self.ui.active_art)
self.drag_renderable = SelectionRenderable(self.ui.app, self.ui.active_art)
icon = self.ui.asset_dir + self.icon_filename_normal
self.icon_texture = self.load_icon_texture(icon)
icon = self.ui.asset_dir + self.icon_filename_add
self.icon_texture_add = self.load_icon_texture(icon)
icon = self.ui.asset_dir + self.icon_filename_sub
self.icon_texture_sub = self.load_icon_texture(icon)
def get_icon_texture(self):
# show different icons based on mod key status
if self.ui.app.il.shift_pressed:
return self.icon_texture_add
elif self.ui.app.il.ctrl_pressed:
return self.icon_texture_sub
else:
return self.icon_texture
def start_select(self):
self.selection_in_progress = True
self.current_drag = {}
x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y)
self.drag_start_x, self.drag_start_y = x, y
# print('started select drag at %s,%s' % (x, y))
def finish_select(self, add_to_selection, subtract_from_selection):
self.selection_in_progress = False
# selection boolean operations:
# shift = add, ctrl = subtract, neither = replace
if not add_to_selection and not subtract_from_selection:
self.selected_tiles = self.current_drag.copy()
elif add_to_selection:
for tile in self.current_drag:
self.selected_tiles[tile] = True
elif subtract_from_selection:
for tile in self.current_drag:
self.selected_tiles.pop(tile, None)
self.current_drag = {}
# x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y)
# print('finished select drag at %s,%s' % (x, y))
def update(self):
if not self.ui.active_art:
return
# update drag based on cursor
# context: cursor has already updated, UI.update calls this
if self.selection_in_progress:
self.current_drag = {}
start_x, start_y = int(self.drag_start_x), int(self.drag_start_y)
end_x, end_y = int(self.ui.app.cursor.x), int(-self.ui.app.cursor.y)
if start_x > end_x:
(
start_x,
end_x,
) = end_x, start_x
if start_y > end_y:
(
start_y,
end_y,
) = end_y, start_y
# always grow to include cursor's tile
end_x += 1
end_y += 1
w, h = self.ui.active_art.width, self.ui.active_art.height
for y in range(start_y, end_y):
for x in range(start_x, end_x):
# never allow out-of-bounds tiles to be selected
if 0 <= x < w and 0 <= y < h:
self.current_drag[(x, y)] = True
# if selection or drag tiles have updated since last update,
# tell our renderables to update
if self.selected_tiles != self.last_selection:
self.select_renderable.rebuild_geo(self.selected_tiles)
self.select_renderable.rebind_buffers()
if self.current_drag != self.last_drag:
self.drag_renderable.rebuild_geo(self.current_drag)
self.drag_renderable.rebind_buffers()
self.last_selection = self.selected_tiles.copy()
self.last_drag = self.current_drag.copy()
def render_selections(self):
if len(self.selected_tiles) > 0:
self.select_renderable.render()
if len(self.current_drag) > 0:
self.drag_renderable.render()
class PasteTool(UITool):
name = "paste"
button_caption = "Paste"
brush_size = None
icon_filename = "tool_paste.png"
# TODO!: dragging large pastes around seems heck of slow, investigate
# why this function might be to blame and see if there's a fix!
def get_paint_commands(self):
# for each command in UI.clipboard, update edit command tile with
# set_before so we can hover/undo/redo properly
commands = []
# similar to PencilTool's get_paint_commands, but "tiles under brush"
# isn't as straightforward here
art = self.ui.active_art
for tc in self.ui.clipboard:
# deep copy of each clipboard command
new_tc = tc.copy()
# not much depends on EditCommand.art at the moment, set it just
# to be safe
# TODO: determine whether it makes sense to remove it entirely
new_tc.art = art
frame, layer, x, y = new_tc.frame, new_tc.layer, new_tc.x, new_tc.y
frame = art.active_frame
layer = art.active_layer
# offset cursor position, center paste on cursor
x += int(self.ui.app.cursor.x) - int(self.ui.clipboard_width / 2)
y -= int(self.ui.app.cursor.y) + int(self.ui.clipboard_height / 2)
if not (0 <= x < art.width and 0 <= y < art.height):
continue
# if a selection is active, only paint inside it
if len(self.ui.select_tool.selected_tiles) > 0:
if not self.ui.select_tool.selected_tiles.get((x, y), False):
continue
b_char, b_fg, b_bg, b_xform = self.ui.active_art.get_tile_at(
frame, layer, x, y
)
new_tc.set_before(b_char, b_fg, b_bg, b_xform)
new_tc.set_tile(frame, layer, x, y)
# respect affects masks like other tools
a_char = new_tc.a_char if self.affects_char else b_char
a_fg = new_tc.a_fg if self.affects_fg_color else b_fg
a_bg = new_tc.a_bg if self.affects_bg_color else b_bg
a_xform = new_tc.a_xform if self.affects_xform else b_xform
new_tc.set_after(a_char, a_fg, a_bg, a_xform)
# see comment at end of PencilTool.get_paint_commands
if not new_tc.is_null():
commands.append(new_tc)
return commands
# "fill boundary" modes: character, fg color, bg color
FILL_BOUND_CHAR = 0
FILL_BOUND_FG_COLOR = 1
FILL_BOUND_BG_COLOR = 2
class FillTool(UITool):
name = "fill"
button_caption = "Fill"
brush_size = None
icon_filename = "tool_fill_char.png" # used only for toolbar
# icons and strings for different boundary modes
icon_filename_char = "tool_fill_char.png"
icon_filename_fg = "tool_fill_fg.png"
icon_filename_bg = "tool_fill_bg.png"
boundary_mode = FILL_BOUND_CHAR
# user-facing names for the boundary modes
boundary_mode_names = {
FILL_BOUND_CHAR: "character",
FILL_BOUND_FG_COLOR: "fg color",
FILL_BOUND_BG_COLOR: "bg color",
}
# determine cycling order
next_boundary_modes = {
FILL_BOUND_CHAR: FILL_BOUND_FG_COLOR,
FILL_BOUND_FG_COLOR: FILL_BOUND_BG_COLOR,
FILL_BOUND_BG_COLOR: FILL_BOUND_CHAR,
}
def __init__(self, ui):
UITool.__init__(self, ui)
icon = self.ui.asset_dir + self.icon_filename_char
self.icon_texture_char = self.load_icon_texture(icon)
icon = self.ui.asset_dir + self.icon_filename_fg
self.icon_texture_fg = self.load_icon_texture(icon)
icon = self.ui.asset_dir + self.icon_filename_bg
self.icon_texture_bg = self.load_icon_texture(icon)
def get_icon_texture(self):
# show different icon based on boundary type
return [self.icon_texture_char, self.icon_texture_fg, self.icon_texture_bg][
self.boundary_mode
]
def get_button_caption(self):
return "{} ({} bounded)".format(
self.button_caption,
self.boundary_mode_names[self.boundary_mode],
)