import platform from collections import namedtuple import sdl2 from key_shifts import SHIFT_MAP from ui_button import TEXT_CENTER, UIButton from ui_colors import UIColors from ui_element import UIElement Field = namedtuple( "Field", [ "label", # text label for field "type", # supported: str int float bool "width", # width in tiles of the field "oneline", ], ) # label and field drawn on same line # "null" field type that tells UI drawing to skip it class SkipFieldType: pass class ConfirmButton(UIButton): caption = "Confirm" caption_justify = TEXT_CENTER width = len(caption) + 2 dimmed_fg_color = UIColors.lightgrey dimmed_bg_color = UIColors.white class CancelButton(ConfirmButton): caption = "Cancel" width = len(caption) + 2 class OtherButton(ConfirmButton): "button for 3rd option in some dialogs, eg Don't Save" caption = "Other" width = len(caption) + 2 visible = False class UIDialog(UIElement): tile_width, tile_height = 40, 8 # extra lines added to height beyond contents length extra_lines = 0 fg_color = UIColors.black bg_color = UIColors.white title = "Test Dialog Box" # string message not tied to a specific field message = None other_button_visible = False titlebar_fg_color = UIColors.white titlebar_bg_color = UIColors.black fields = [] # list of tuples of field #s for linked radio button options radio_groups = [] default_field_width = 36 default_short_field_width = int(default_field_width / 4) # default amount of lines padding each field y_spacing = 1 active_field_fg_color = UIColors.white active_field_bg_color = UIColors.darkgrey inactive_field_fg_color = UIColors.black inactive_field_bg_color = UIColors.lightgrey # allow subclasses to override confirm caption, eg Save confirm_caption = None other_caption = None cancel_caption = None # center in window vs use tile_x/y to place center_in_window = True # checkbox char index (UI charset) checkbox_char_index = 131 # radio buttons, filled and unfilled radio_true_char_index = 127 radio_false_char_index = 126 # field text set for bool fields with True value true_field_text = "x" # if True, field labels will redraw with fields after handling input always_redraw_labels = False def __init__(self, ui, options): self.ui = ui # apply options, eg passed in from UI.open_dialog for k, v in options.items(): setattr(self, k, v) self.confirm_button = ConfirmButton(self) self.other_button = OtherButton(self) self.cancel_button = CancelButton(self) # handle caption overrides def caption_override(button, alt_caption): if alt_caption and button.caption != alt_caption: button.caption = alt_caption button.width = len(alt_caption) + 2 caption_override(self.confirm_button, self.confirm_caption) caption_override(self.other_button, self.other_caption) caption_override(self.cancel_button, self.cancel_caption) self.confirm_button.callback = self.confirm_pressed self.other_button.callback = self.other_pressed self.cancel_button.callback = self.cancel_pressed self.buttons = [self.confirm_button, self.other_button, self.cancel_button] # populate fields with text self.field_texts = [] for i, field in enumerate(self.fields): self.field_texts.append(self.get_initial_field_text(i)) # field cursor starts on self.active_field = 0 UIElement.__init__(self, ui) if self.ui.menu_bar and self.ui.menu_bar.active_menu_name: self.ui.menu_bar.close_active_menu() def get_initial_field_text(self, field_number): "subclasses specify a given field's initial text here" return "" def get_height(self, msg_lines): "determine size based on contents (subclasses can use custom logic)" # base height = 4, titlebar + padding + buttons + padding h = 4 h += 0 if len(msg_lines) == 0 else len(msg_lines) + 1 # determine height of each field from self.fields for field in self.fields: if field.type is SkipFieldType: continue elif field.oneline or field.type is bool or field.type is None: h += self.y_spacing + 1 else: h += self.y_spacing + 2 h += self.extra_lines return h def reset_art(self, resize=True, clear_buttons=True): # get_message splits into >1 line if too long msg_lines = self.get_message() if self.message else [] if resize: self.tile_height = self.get_height(msg_lines) self.art.resize(self.tile_width, self.tile_height) if self.center_in_window: qw, qh = self.art.quad_width, self.art.quad_height self.x = -(self.tile_width * qw) / 2 self.y = (self.tile_height * qh) / 2 # draw window self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color) s = " " + self.title.ljust(self.tile_width - 1) # invert titlebar (if kb focus) fg = self.titlebar_fg_color bg = self.titlebar_bg_color if self is not self.ui.keyboard_focus_element and self is self.ui.active_dialog: fg = self.fg_color bg = self.bg_color self.art.write_string(0, 0, 0, 0, s, fg, bg) # message if self.message: y = 2 for i, line in enumerate(msg_lines): self.art.write_string(0, 0, 2, y + i, line) # field caption(s) self.draw_fields() # position buttons self.confirm_button.x = self.tile_width - self.confirm_button.width - 2 self.confirm_button.y = self.tile_height - 2 if self.other_button_visible: self.other_button.x = self.confirm_button.x self.other_button.x -= self.other_button.width + 2 self.other_button.y = self.confirm_button.y self.other_button.visible = True self.cancel_button.x = 2 self.cancel_button.y = self.tile_height - 2 # create field buttons so you can click em if clear_buttons: self.buttons = [self.confirm_button, self.other_button, self.cancel_button] for i, field in enumerate(self.fields): # None-type field = just a label if field.type is None: continue field_button = DialogFieldButton(self) field_button.field_number = i # field settings mean button can be in a variety of places field_button.width = 1 if field.type is bool else field.width field_button.x = ( 2 if not field.oneline or field.type is bool else len(field.label) + 1 ) field_button.y = self.get_field_y(i) if not field.oneline: field_button.y += 1 self.buttons.append(field_button) # draw buttons UIElement.reset_art(self) def update_drag(self, mouse_dx, mouse_dy): win_w, win_h = self.ui.app.window_width, self.ui.app.window_height self.x += (mouse_dx / win_w) * 2 self.y -= (mouse_dy / win_h) * 2 self.renderable.x, self.renderable.y = self.x, self.y def hovered(self): # mouse hover on focus if ( self.ui.app.mouse_dx or self.ui.app.mouse_dy ) and self is not self.ui.keyboard_focus_element: self.ui.keyboard_focus_element = self self.reset_art() def update(self): # redraw fields every update for cursor blink # (seems a waste, no real perf impact tho) self.draw_fields(False) # don't allow confirmation if all field input isn't valid valid, reason = self.is_input_valid() # if input invalid, show reason in red along button of window bottom_y = self.tile_height - 1 # first clear any previous warnings self.art.clear_line(0, 0, bottom_y) self.confirm_button.set_state("normal") # some dialogs use reason for warning + valid input if reason: fg = self.ui.error_color_index self.art.write_string(0, 0, 1, bottom_y, reason, fg) if not valid: self.confirm_button.set_state("dimmed") UIElement.update(self) def get_message(self): # if a triple quoted string, split line breaks msg = self.message.rstrip().split("\n") msg_lines = [] for line in msg: if line != "": msg_lines.append(line) # TODO: split over multiple lines if too long return msg_lines def get_field_colors(self, index): "return FG and BG colors for field with given index" fg, bg = self.inactive_field_fg_color, self.inactive_field_bg_color # only highlight active field if we have kb focus if self is self.ui.keyboard_focus_element and index == self.active_field: fg, bg = self.active_field_fg_color, self.active_field_bg_color return fg, bg def get_field_label(self, field_index): "Subclasses can override to do custom label logic eg string formatting" return self.fields[field_index].label def draw_fields(self, with_labels=True): y = 2 if self.message: y += len(self.get_message()) + 1 for i, field in enumerate(self.fields): if field.type is SkipFieldType: continue x = 2 # bool values: checkbox or radio button, always draw label to right if field.type is bool: # if field index is in any radio group, it's a radio button is_radio = False for group in self.radio_groups: if i in group: is_radio = True break # true/false ~ field text is 'x' field_true = self.field_texts[i] == self.true_field_text if is_radio: char = ( self.radio_true_char_index if field_true else self.radio_false_char_index ) else: char = self.checkbox_char_index if field_true else 0 fg, bg = self.get_field_colors(i) self.art.set_tile_at(0, 0, x, y, char, fg, bg) x += 2 # draw label if field.label: label = self.get_field_label(i) if with_labels: self.art.clear_line(0, 0, y) self.art.write_string(0, 0, x, y, label, self.fg_color) if field.type in [bool, None]: pass elif field.oneline: x += len(label) + 1 else: y += 1 # draw field contents if field.type not in [bool, None]: fg, bg = self.get_field_colors(i) text = self.field_texts[i] # caret for active field (if kb focus) if i == self.active_field and self is self.ui.keyboard_focus_element: blink_on = int(self.ui.app.get_elapsed_time() / 250) % 2 if blink_on: text += "_" # pad with spaces to full width of field text = text.ljust(field.width) self.art.write_string(0, 0, x, y, text, fg, bg) y += self.y_spacing + 1 def get_field_y(self, field_index): "returns a Y value for where the given field (caption) should start" y = 2 # add # of message lines if self.message: y += len(self.get_message()) + 1 for i in range(field_index): if self.fields[i].oneline or self.fields[i].type in [bool, None]: y += self.y_spacing + 1 else: y += self.y_spacing + 2 return y def get_toggled_bool_field(self, field_index): field_text = self.field_texts[field_index] on = field_text == self.true_field_text # if in a radio group and turning on, toggle off the others radio_button = False for group in self.radio_groups: if field_index in group: radio_button = True if not on: for i in group: if i != field_index: self.field_texts[i] = " " break # toggle checkbox if not radio_button: return " " if on else self.true_field_text # only toggle radio button on; selecting others toggles it off elif on: return field_text else: return self.true_field_text def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed): keystr = sdl2.SDL_GetKeyName(key).decode() field = None field_text = "" if self.active_field < len(self.fields): field = self.fields[self.active_field] field_text = self.field_texts[self.active_field] # special case: shortcut 'D' for 3rd button if no field input if len(self.fields) == 0 and keystr.lower() == "d": self.other_pressed() return if keystr == "`" and not shift_pressed: self.ui.console.toggle() return # if list panel is up don't let user tab away lp = self.ui.edit_list_panel # only allow tab to focus shift IF list panel accepts it if ( keystr == "Tab" and lp.is_visible() and lp.list_operation in lp.list_operations_allow_kb_focus ): self.ui.keyboard_focus_element = self.ui.edit_list_panel return elif keystr == "Return": self.confirm_pressed() elif keystr == "Escape": self.cancel_pressed() # cycle through fields with up/down elif keystr == "Up" or (keystr == "Tab" and shift_pressed): if len(self.fields) > 1: self.active_field -= 1 self.active_field %= len(self.fields) # skip over None-type fields aka dead labels while ( self.fields[self.active_field].type is None or self.fields[self.active_field].type is SkipFieldType ): self.active_field -= 1 self.active_field %= len(self.fields) return elif keystr == "Down" or keystr == "Tab": if len(self.fields) > 1: self.active_field += 1 self.active_field %= len(self.fields) while ( self.fields[self.active_field].type is None or self.fields[self.active_field].type is SkipFieldType ): self.active_field += 1 self.active_field %= len(self.fields) return elif keystr == "Backspace": if len(field_text) == 0 or field and field.type is bool: pass elif alt_pressed: # for file dialogs, delete back to last slash last_slash = field_text[:-1].rfind("/") # on windows, recognize backslash as well if platform.system() == "Windows": last_backslash = field_text[:-1].rfind("\\") if last_backslash != -1 and last_slash != -1: last_slash = min(last_backslash, last_slash) if last_slash == -1: field_text = "" else: field_text = field_text[: last_slash + 1] else: field_text = field_text[:-1] elif keystr == "Space": # if field.type is bool, toggle value if field.type is bool: field_text = self.get_toggled_bool_field(self.active_field) else: field_text += " " elif len(keystr) > 1: return # alphanumeric text input elif field and field.type is not bool: if field.type is str: if not shift_pressed: keystr = keystr.lower() if not keystr.isalpha() and shift_pressed: keystr = SHIFT_MAP.get(keystr, "") elif ( field.type is int and not keystr.isdigit() and keystr != "-" or field.type is float and not keystr.isdigit() and keystr != "." and keystr != "-" ): return field_text += keystr # apply new field text and redraw if field and (len(field_text) < field.width or field.type is bool): self.field_texts[self.active_field] = field_text self.draw_fields(self.always_redraw_labels) def is_input_valid(self): "subclasses that want to filter input put logic here" return True, None def dismiss(self): # let UI forget about us self.ui.active_dialog = None if self is self.ui.keyboard_focus_element: self.ui.keyboard_focus_element = None self.ui.refocus_keyboard() self.ui.elements.remove(self) def confirm_pressed(self): # subclasses do more here :] self.dismiss() def cancel_pressed(self): self.dismiss() def other_pressed(self): self.dismiss() class DialogFieldButton(UIButton): "invisible button that provides clickability for input fields" caption = "" # re-set by dialog constructor field_number = 0 never_draw = True def click(self): UIButton.click(self) self.element.active_field = self.field_number # toggle if a bool field if self.element.fields[self.field_number].type is bool: self.element.field_texts[self.field_number] = ( self.element.get_toggled_bool_field(self.field_number) ) # redraw fields & labels self.element.draw_fields(self.element.always_redraw_labels)