import platform import sdl2 from collections import namedtuple from ui_element import UIElement from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT from ui_colors import UIColors from key_shifts import SHIFT_MAP 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 not self is 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 \ not self is 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 not field.type 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: pass # don't let user clear a bool value # TODO: allow for checkboxes but not radio buttons elif 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 not field.type is 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 != '-': return # this doesn't guard against things like 0.00.001 elif 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)