playscii/ui_art_dialog.py

814 lines
27 KiB
Python

import os.path
from art import (
ART_DIR,
ART_FILE_EXTENSION,
DEFAULT_FRAME_DELAY,
DEFAULT_HEIGHT,
DEFAULT_LAYER_Z_OFFSET,
DEFAULT_WIDTH,
)
from palette import PaletteFromFile
from ui_chooser_dialog import ChooserDialog, ChooserItem, ChooserItemButton
from ui_console import SaveCommand
from ui_dialog import Field, UIDialog
class BaseFileDialog(UIDialog):
invalid_filename_error = "Filename is not valid."
filename_exists_error = "File by that name already exists."
def get_file_extension(self):
return ""
def get_dir(self):
return ""
def get_full_filename(self, filename, dir=None):
for forbidden_char in self.ui.app.forbidden_filename_chars:
if forbidden_char in filename:
return
full_filename = self.get_dir() + "/" + filename
full_filename += "." + self.get_file_extension()
return full_filename
def is_filename_valid(self, field_number):
filename = self.field_texts[field_number].strip()
# filename can't be only whitespace
if not filename:
return False, self.invalid_filename_error
full_filename = self.get_full_filename(filename)
if not full_filename:
return False, self.invalid_filename_error
# if file exists, allow saving but show the warning
if os.path.exists(full_filename):
return True, self.filename_exists_error
return True, None
class NewArtDialog(BaseFileDialog):
title = "New art"
field0_label = "Filename of new art:"
field2_label = "Width:"
field4_label = "Height:"
field6_label = "Save folder:"
field7_label = " %s"
tile_width = 60
field0_width = 56
y_spacing = 0
field1_width = field2_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=str, width=field0_width, oneline=False),
Field(label="", type=None, width=0, oneline=True),
Field(label=field2_label, type=int, width=field1_width, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field4_label, type=int, width=field2_width, oneline=True),
Field(label="", type=None, width=0, oneline=True),
Field(label=field6_label, type=None, width=0, oneline=True),
Field(label=field7_label, type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
]
confirm_caption = "Create"
invalid_width_error = "Invalid width."
invalid_height_error = "Invalid height."
def get_initial_field_text(self, field_number):
if field_number == 0:
return "new{}".format(len(self.ui.app.art_loaded_for_edit))
elif field_number == 2:
return str(DEFAULT_WIDTH)
elif field_number == 4:
return str(DEFAULT_HEIGHT)
return ""
def get_field_label(self, field_index):
label = self.fields[field_index].label
# show dir art will be saved into
if field_index == 7:
label %= self.get_dir()
return label
def get_file_extension(self):
return ART_FILE_EXTENSION
def get_dir(self):
return self.ui.app.documents_dir + ART_DIR
def is_input_valid(self):
"warn if file already exists, dimensions must be >0 and <= max"
if not self.is_valid_dimension(self.field_texts[2], self.ui.app.max_art_width):
return False, self.invalid_width_error
if not self.is_valid_dimension(self.field_texts[4], self.ui.app.max_art_height):
return False, self.invalid_height_error
return self.is_filename_valid(0)
def is_valid_dimension(self, dimension, max_dimension):
try:
dimension = int(dimension)
except Exception:
return False
return 0 < dimension <= max_dimension
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
name = self.field_texts[0]
w, h = int(self.field_texts[2]), int(self.field_texts[4])
self.ui.app.new_art_for_edit(name, w, h)
self.ui.app.log("Created {}.psci with size {} x {}".format(name, w, h))
self.dismiss()
class SaveAsDialog(BaseFileDialog):
title = "Save art"
field0_label = "New filename for art:"
field2_label = "Save folder:"
field3_label = " %s"
tile_width = 60
field0_width = 56
y_spacing = 0
fields = [
Field(label=field0_label, type=str, width=field0_width, oneline=False),
Field(label="", type=None, width=0, oneline=True),
Field(label=field2_label, type=None, width=0, oneline=True),
Field(label=field3_label, type=None, width=0, oneline=True),
Field(label="", type=None, width=0, oneline=True),
]
confirm_caption = "Save"
always_redraw_labels = True
def get_initial_field_text(self, field_number):
if field_number == 0:
# special case: if opening playscii/art/new, change
# it to documents dir to avoid writing to application dir
# (still possible if you open other files)
if os.path.dirname(self.ui.active_art.filename) == ART_DIR[:-1]:
self.ui.active_art.filename = (
self.ui.app.documents_dir + self.ui.active_art.filename
)
# TODO: handle other files from app dir as well? not as important
filename = os.path.basename(self.ui.active_art.filename)
filename = os.path.splitext(filename)[0]
return filename
return ""
def get_file_extension(self):
"""
Return file extension this dialog saves as; other dialogs
are based on this class so we don't want to hardcore .psci
"""
return ART_FILE_EXTENSION
def get_dir(self):
return os.path.dirname(self.ui.active_art.filename)
def get_field_label(self, field_index):
label = self.fields[field_index].label
# show dir art will be saved into
if field_index == 3:
label %= self.get_dir()
return label
def is_input_valid(self):
return self.is_filename_valid(0)
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
SaveCommand.execute(self.ui.console, [self.field_texts[0]])
self.dismiss()
class ConvertItemButton(ChooserItemButton):
width = 15
big_width = 20
class ConvertChooserItem(ChooserItem):
def picked(self, element):
# TODO: following is c+p'd from BaseFileChooserItem.picked,
# move this functionality into ChooserItem and override in BFCI?
# if this is different from the last clicked item, pick it
if element.selected_item_index != self.index:
ChooserItem.picked(self, element)
element.first_selection_made = True
return
# if we haven't yet clicked something in this view, require another
# click before opening it (consistent double click behavior for
# initial selections)
if not element.first_selection_made:
element.first_selection_made = True
return
element.confirm_pressed()
element.first_selection_made = False
def get_description_lines(self):
return self.description.split("\n")
class ConvertFileDialog(ChooserDialog):
"Common functionality for importer and exporter selection dialogs"
tile_width, big_width = 85, 90
tile_height, big_height = 15, 25
confirm_caption = "Choose"
show_preview_image = False
item_button_class = ConvertItemButton
chooser_item_class = ConvertChooserItem
def get_converters(self):
return []
def get_items(self):
items = []
converters = self.get_converters()
# sort alphabetically
converters.sort(key=lambda item: item.format_name.lower())
i = 0
for converter in converters:
item = self.chooser_item_class(i, converter.__name__)
item.converter_class = converter
item.label = converter.format_name
item.description = converter.format_description
items.append(item)
i += 1
return items
class ImportFileDialog(ConvertFileDialog):
title = "Choose an importer"
def get_converters(self):
return self.ui.app.get_importers()
def confirm_pressed(self):
# open file select dialog so user can choose what to import
item = self.get_selected_item()
self.ui.app.importer = item.converter_class
if not self.ui.app.importer:
return
self.dismiss()
self.ui.open_dialog(self.ui.app.importer.file_chooser_dialog_class)
class ImportOptionsDialog(UIDialog):
"Generic base class for importer options"
confirm_caption = "Import"
def do_import(app, filename, options):
"Common 'run importer' code for end of import options dialog"
# if importer needs no options, run it
importer = app.importer(app, filename, options)
if importer.success:
if app.importer.completes_instantly:
app.log("Imported {} successfully.".format(filename))
app.importer = None
class ExportOptionsDialog(UIDialog):
"Generic base class for exporter options"
confirm_caption = "Export"
def do_export(app, filename, options):
"Common 'run exporter' code for end of import options dialog"
# if importer needs no options, run it
exporter = app.exporter(app, filename, options)
if exporter.success:
app.log("Exported {} successfully.".format(exporter.out_filename))
class ExportFileDialog(ConvertFileDialog):
title = "Choose an exporter"
def get_converters(self):
return self.ui.app.get_exporters()
def confirm_pressed(self):
# open file select dialog so user can choose what to import
item = self.get_selected_item()
self.ui.app.exporter = item.converter_class
if not self.ui.app.exporter:
return
self.dismiss()
self.ui.open_dialog(ExportFilenameInputDialog)
class ExportFilenameInputDialog(SaveAsDialog):
title = "Export art"
field0_label = "New filename for exported art:"
confirm_caption = "Export"
def get_initial_field_text(self, field_number):
# base output filename on art filename
if field_number == 0:
out_filename = self.ui.active_art.filename
out_filename = os.path.basename(out_filename)
out_filename = os.path.splitext(out_filename)[0]
return out_filename
def get_file_extension(self):
return self.ui.app.exporter.file_extension
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
filename = self.field_texts[0]
self.dismiss()
# invoke options dialog if exporter has one, else invoke exporter
if self.ui.app.exporter.options_dialog_class:
# pass filename into new dialog
options = {"filename": filename}
self.ui.open_dialog(self.ui.app.exporter.options_dialog_class, options)
else:
ExportOptionsDialog.do_export(self.ui.app, filename, {})
class QuitUnsavedChangesDialog(UIDialog):
title = "Unsaved changes"
message = "Save changes to %s?"
confirm_caption = "Save"
other_button_visible = True
other_caption = "Don't Save"
def confirm_pressed(self):
SaveCommand.execute(self.ui.console, [])
self.dismiss()
# try again, see if another art has unsaved changes
self.ui.app.il.BIND_quit()
def other_pressed(self):
# kind of a hack: make the check BIND_quit does come up false
# for this art. externalities fairly minor.
self.ui.active_art.unsaved_changes = False
self.dismiss()
self.ui.app.il.BIND_quit()
def get_message(self):
# get base name (ie no dirs)
filename = os.path.basename(self.ui.active_art.filename)
return [self.message % filename]
class CloseUnsavedChangesDialog(QuitUnsavedChangesDialog):
def confirm_pressed(self):
SaveCommand.execute(self.ui.console, [])
self.dismiss()
self.ui.app.il.BIND_close_art()
def other_pressed(self):
self.ui.active_art.unsaved_changes = False
self.dismiss()
self.ui.app.il.BIND_close_art()
class RevertChangesDialog(UIDialog):
title = "Revert changes"
message = "Revert changes to %s?"
confirm_caption = "Revert"
def confirm_pressed(self):
self.ui.app.revert_active_art()
self.dismiss()
def get_message(self):
filename = os.path.basename(self.ui.active_art.filename)
return [self.message % filename]
class ResizeArtDialog(UIDialog):
title = "Resize art"
field_width = UIDialog.default_short_field_width
field0_label = "New Width:"
field1_label = "New Height:"
field2_label = "Crop Start X:"
field3_label = "Crop Start Y:"
field4_label = "Fill new tiles with BG color"
fields = [
Field(label=field0_label, type=int, width=field_width, oneline=True),
Field(label=field1_label, type=int, width=field_width, oneline=True),
Field(label=field2_label, type=int, width=field_width, oneline=True),
Field(label=field3_label, type=int, width=field_width, oneline=True),
Field(label=field4_label, type=bool, width=0, oneline=True),
]
confirm_caption = "Resize"
invalid_width_error = "Invalid width."
invalid_height_error = "Invalid height."
invalid_start_error = "Invalid crop origin."
def get_initial_field_text(self, field_number):
if field_number == 0:
return str(self.ui.active_art.width)
elif field_number == 1:
return str(self.ui.active_art.height)
elif field_number == 4:
return UIDialog.true_field_text
else:
return "0"
def is_input_valid(self):
"file can't already exist, dimensions must be >0 and <= max"
if not self.is_valid_dimension(self.field_texts[0], self.ui.app.max_art_width):
return False, self.invalid_width_error
if not self.is_valid_dimension(self.field_texts[1], self.ui.app.max_art_height):
return False, self.invalid_height_error
try:
int(self.field_texts[2])
except Exception:
return False, self.invalid_start_error
if not 0 <= int(self.field_texts[2]) < self.ui.active_art.width:
return False, self.invalid_start_error
try:
int(self.field_texts[3])
except Exception:
return False, self.invalid_start_error
if not 0 <= int(self.field_texts[3]) < self.ui.active_art.height:
return False, self.invalid_start_error
return True, None
def is_valid_dimension(self, dimension, max_dimension):
try:
dimension = int(dimension)
except Exception:
return False
return 0 < dimension <= max_dimension
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
w, h = int(self.field_texts[0]), int(self.field_texts[1])
start_x, start_y = int(self.field_texts[2]), int(self.field_texts[3])
bg_fill = bool(self.field_texts[4].strip())
self.ui.resize_art(self.ui.active_art, w, h, start_x, start_y, bg_fill)
self.dismiss()
#
# layer menu dialogs
#
class AddFrameDialog(UIDialog):
title = "Add new frame"
field0_label = "Index to add frame before:"
field1_label = "Hold time (in seconds) for new frame:"
field_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=int, width=field_width, oneline=True),
Field(label=field1_label, type=float, width=field_width, oneline=False),
]
confirm_caption = "Add"
invalid_index_error = "Invalid index. (1-%s allowed)"
invalid_delay_error = "Invalid hold time."
def get_initial_field_text(self, field_number):
if field_number == 0:
return str(self.ui.active_art.frames + 1)
elif field_number == 1:
return str(DEFAULT_FRAME_DELAY)
def is_valid_frame_index(self, index):
try:
index = int(index)
except Exception:
return False
if index < 1 or index > self.ui.active_art.frames + 1:
return False
return True
def is_valid_frame_delay(self, delay):
try:
delay = float(delay)
except Exception:
return False
return delay > 0
def is_input_valid(self):
if not self.is_valid_frame_index(self.field_texts[0]):
return False, self.invalid_index_error % str(self.ui.active_art.frames + 1)
if not self.is_valid_frame_delay(self.field_texts[1]):
return False, self.invalid_delay_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
index = int(self.field_texts[0])
delay = float(self.field_texts[1])
self.ui.active_art.insert_frame_before_index(index - 1, delay)
self.dismiss()
class DuplicateFrameDialog(AddFrameDialog):
title = "Duplicate frame"
confirm_caption = "Duplicate"
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
index = int(self.field_texts[0])
delay = float(self.field_texts[1])
self.ui.active_art.duplicate_frame(
self.ui.active_art.active_frame, index - 1, delay
)
self.dismiss()
class FrameDelayDialog(AddFrameDialog):
field0_label = "New hold time (in seconds) for frame:"
field_width = UIDialog.default_short_field_width
fields = [Field(label=field0_label, type=float, width=field_width, oneline=False)]
confirm_caption = "Set"
def get_initial_field_text(self, field_number):
if field_number == 0:
return str(self.ui.active_art.frame_delays[self.ui.active_art.active_frame])
def is_input_valid(self):
if not self.is_valid_frame_delay(self.field_texts[0]):
return False, self.invalid_delay_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
delay = float(self.field_texts[0])
self.ui.active_art.frame_delays[self.ui.active_art.active_frame] = delay
self.dismiss()
class FrameDelayAllDialog(FrameDelayDialog):
field0_label = "New hold time (in seconds) for all frames:"
field_width = UIDialog.default_short_field_width
fields = [Field(label=field0_label, type=float, width=field_width, oneline=False)]
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
delay = float(self.field_texts[0])
for i in range(self.ui.active_art.frames):
self.ui.active_art.frame_delays[i] = delay
self.dismiss()
class FrameIndexDialog(AddFrameDialog):
field0_label = "Move this frame before index:"
field_width = UIDialog.default_short_field_width
fields = [Field(label=field0_label, type=int, width=field_width, oneline=False)]
confirm_caption = "Set"
def is_input_valid(self):
if not self.is_valid_frame_index(self.field_texts[0]):
return False, self.invalid_index_error % str(self.ui.active_art.frames + 1)
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
# set new frame index (effectively moving it in the sequence)
dest_index = int(self.field_texts[0])
self.ui.active_art.move_frame_to_index(
self.ui.active_art.active_frame, dest_index
)
self.dismiss()
#
# layer menu dialogs
#
class AddLayerDialog(UIDialog):
title = "Add new layer"
field0_label = "Name for new layer:"
field1_label = "Z-depth for new layer:"
field0_width = UIDialog.default_field_width
field1_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=str, width=field0_width, oneline=False),
Field(label=field1_label, type=float, width=field1_width, oneline=True),
]
confirm_caption = "Add"
name_exists_error = "Layer by that name already exists."
invalid_z_error = "Invalid number."
def get_initial_field_text(self, field_number):
if field_number == 0:
return "Layer {}".format(str(self.ui.active_art.layers + 1))
elif field_number == 1:
return str(
self.ui.active_art.layers_z[self.ui.active_art.active_layer]
+ DEFAULT_LAYER_Z_OFFSET
)
def is_valid_layer_name(self, name, exclude_active_layer=False):
for i, layer_name in enumerate(self.ui.active_art.layer_names):
if exclude_active_layer and i == self.ui.active_layer:
continue
if layer_name == name:
return False
return True
def is_input_valid(self):
valid_name = self.is_valid_layer_name(self.field_texts[0])
if not valid_name:
return False, self.name_exists_error
try:
float(self.field_texts[1])
except Exception:
return False, self.invalid_z_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
name = self.field_texts[0]
z = float(self.field_texts[1])
self.ui.active_art.add_layer(z, name)
self.dismiss()
class DuplicateLayerDialog(AddLayerDialog):
title = "Duplicate layer"
confirm_caption = "Duplicate"
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
name = self.field_texts[0]
z = float(self.field_texts[1])
self.ui.active_art.duplicate_layer(self.ui.active_art.active_layer, z, name)
self.dismiss()
class SetLayerNameDialog(AddLayerDialog):
title = "Set layer name"
field0_label = "New name for this layer:"
field_width = UIDialog.default_field_width
fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)]
confirm_caption = "Rename"
def confirm_pressed(self):
new_name = self.field_texts[0]
self.ui.active_art.layer_names[self.ui.active_art.active_layer] = new_name
self.ui.active_art.set_unsaved_changes(True)
self.dismiss()
class SetLayerZDialog(UIDialog):
title = "Set layer Z-depth"
field0_label = "Z-depth for layer:"
field_width = UIDialog.default_short_field_width
fields = [Field(label=field0_label, type=float, width=field_width, oneline=False)]
confirm_caption = "Set"
invalid_z_error = "Invalid number."
def get_initial_field_text(self, field_number):
# populate with existing z
if field_number == 0:
return str(self.ui.active_art.layers_z[self.ui.active_art.active_layer])
def is_input_valid(self):
try:
float(self.field_texts[0])
except Exception:
return False, self.invalid_z_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
new_z = float(self.field_texts[0])
self.ui.active_art.layers_z[self.ui.active_art.active_layer] = new_z
self.ui.active_art.set_unsaved_changes(True)
self.ui.app.grid.reset()
self.dismiss()
class PaletteFromFileDialog(UIDialog):
title = "Create palette from file"
field0_label = "Filename to create palette from:"
field1_label = "Filename for new palette:"
field2_label = "Colors in new palette:"
field0_width = field1_width = UIDialog.default_field_width
field2_width = UIDialog.default_short_field_width
fields = [
Field(label=field0_label, type=str, width=field0_width, oneline=False),
Field(label=field1_label, type=str, width=field1_width, oneline=False),
Field(label=field2_label, type=int, width=field2_width, oneline=True),
]
confirm_caption = "Create"
invalid_color_error = "Palettes must be between 2 and 256 colors."
bad_output_filename_error = "Enter a filename for the new palette."
def get_initial_field_text(self, field_number):
# NOTE: PaletteFromImageChooserDialog.confirm_pressed which invokes us
# sets fields 0 and 1
if field_number == 2:
return str(256)
return ""
def valid_colors(self, colors):
try:
c = int(colors)
except Exception:
return False
return 2 <= c <= 256
def is_input_valid(self):
valid_colors = self.valid_colors(self.field_texts[2])
if not valid_colors:
return False, self.invalid_color_error
if not self.field_texts[1].strip():
return False, self.bad_output_filename_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
src_filename = self.field_texts[0]
palette_filename = self.field_texts[1]
colors = int(self.field_texts[2])
PaletteFromFile(self.ui.app, src_filename, palette_filename, colors)
self.dismiss()
class SetCameraZoomDialog(UIDialog):
title = "Set camera zoom"
field0_label = "New camera zoom %:"
field_width = UIDialog.default_short_field_width
fields = [Field(label=field0_label, type=float, width=field_width, oneline=True)]
confirm_caption = "Set"
invalid_zoom_error = "Zoom % must be a number greater than zero."
all_modes_visible = True
game_mode_visible = True
def get_initial_field_text(self, field_number):
if field_number == 0:
return "{:.1f}".format(self.ui.app.camera.get_current_zoom_pct())
return ""
def is_input_valid(self):
try:
zoom = float(self.field_texts[0])
except Exception:
return False, self.invalid_zoom_error
if zoom <= 0:
return False, self.invalid_zoom_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
new_zoom_pct = float(self.field_texts[0])
camera = self.ui.app.camera
camera.z = camera.get_base_zoom() / (new_zoom_pct / 100)
self.dismiss()
class OverlayImageOpacityDialog(UIDialog):
title = "Set overlay image opacity"
field0_label = "New overlay opacity %:"
field_width = UIDialog.default_short_field_width
fields = [Field(label=field0_label, type=float, width=field_width, oneline=True)]
confirm_caption = "Set"
invalid_opacity_error = "Opacity % must be between 0 and 100."
def get_initial_field_text(self, field_number):
if field_number == 0:
return "%.1f" % (self.ui.app.overlay_renderable.alpha * 100)
return ""
def is_input_valid(self):
try:
opacity = float(self.field_texts[0])
except Exception:
return False, self.invalid_opacity_error
if opacity <= 0 or opacity > 100:
return False, self.invalid_opacity_error
return True, None
def confirm_pressed(self):
valid, reason = self.is_input_valid()
if not valid:
return
new_opacity = float(self.field_texts[0])
self.ui.app.overlay_renderable.alpha = new_opacity / 100
self.dismiss()