diff --git a/00-ofono.rules b/00-ofono.rules new file mode 100644 index 00000000..9c31b5ea --- /dev/null +++ b/00-ofono.rules @@ -0,0 +1,2 @@ +KERNEL=="ttyAMA0", ENV{OFONO_DRIVER}="sim900" + diff --git a/apps/personal/contacts/vcard_converter.py b/apps/personal/contacts/vcard_converter.py index d8d13d05..469ab88d 100644 --- a/apps/personal/contacts/vcard_converter.py +++ b/apps/personal/contacts/vcard_converter.py @@ -1,9 +1,9 @@ -from helpers import setup_logger -logger = setup_logger(__name__, "warning") - import vobject from address_book import Contact +from helpers import setup_logger + +logger = setup_logger(__name__, "info") class VCardContactConverter(object): @@ -52,3 +52,9 @@ def from_vcards(contact_card_files): contacts += VCardContactConverter.parse_vcard_file(file_path) logger.info("finished : {} contacts loaded", len(contacts)) return [VCardContactConverter.to_zpui_contact(c) for c in contacts] + + @classmethod + def from_string(cls, vcard_string): + # type: (str) -> list + # Returns a list of ZPUI contacts from a string in vcard format + return [c for c in vobject.readComponents(vcard_string, ignoreUnreadable=True)] diff --git a/apps/phone/graphics.py b/apps/phone/graphics.py new file mode 100644 index 00000000..749398d0 --- /dev/null +++ b/apps/phone/graphics.py @@ -0,0 +1,55 @@ +# This function allows to insert polygons as a list of points whose code is +# laid out in a shape somewhat similar to the polygon's shape. The problem +# with that is - order of points in the polygon list matters when drawing it, +# so we have to reorder the points - otherwise they'll become an untangled mess. +# So, that's what this function does - untangles the polygons according to +# a given mapping. + +def untangle_points_by_mapping(points, mapping): + new_points = [p for p in points] + for dest, so in enumerate(mapping): + so = int(so) + new_points[dest] = points[so] + return new_points + +# Graphics + +arrow = ( # indices: + (3, 0), # 0 +(0, 3),(2, 3),(4, 3),(6, 3), # 1 2 3 4 + (2, 8),(4, 8) # 5 6 +) + +mapping = "0436521" + +arrow = untangle_points_by_mapping(arrow, mapping) + +cross = ( # indices: + (1, 0), (7, 0), # 0 1 +(0, 1), (8, 1), # 2 3 + (4, 3), # 4 + (3, 4), (5, 4), # 5 6 + (4, 5), # 7 +(0, 7), (8, 7), # 8 9 + (1, 8), (7, 8) # 10 11 +) + +mapping = (0, 4, 1, 3, 6, 9, 11, 7, 10, 8, 5, 2) + +cross = untangle_points_by_mapping(cross, mapping) + +phone_handset = ( + (4, 0), # 0 + (8, 1), # 1 + (4, 3), # 2 + (7, 4), # 3 + + (2, 11), # 4 + (4, 12), # 5 +(0, 13), # 6 + (3, 15) # 7 +) + +mapping = "01324576" + +phone_handset = untangle_points_by_mapping(phone_handset, mapping) diff --git a/apps/phone/main.py b/apps/phone/main.py old mode 100755 new mode 100644 index f56b1dab..c8bac440 --- a/apps/phone/main.py +++ b/apps/phone/main.py @@ -1,137 +1,171 @@ - - -from helpers import setup_logger - -menu_name = "Phone" - -from subprocess import call as os_call from time import sleep -import traceback - -from ui import Refresher, Menu, Printer, PrettyPrinter, DialogBox -from ui.experimental import NumberKeypadInputLayer -from helpers import BackgroundRunner, ExitHelper - -from phone import Phone, Modem, ATError - - -logger = setup_logger(__name__, "warning") - -i = None -o = None -init = None -phone = None - -def answer(): - #No context switching as for now, so all we can do is - #answer call is there's an active one - try: - phone.answer() - except ATError: - pass -def hangup(): - try: - phone.hangup() - except ATError: +from apps import ZeroApp +from ui import Refresher, NumpadNumberInput, Canvas, MockOutput, FunctionOverlay, \ + offset_points, get_bounds_for_points, expand_coords, multiply_points, check_coordinates, \ + convert_flat_list_into_pairs as cflip +from ui.base_ui import BaseUIElement +from ui.base_view_ui import BaseView + +import graphics +from views import InputScreenView + + +class InputScreen(NumpadNumberInput): + + message = "Input number:" + default_pixel_view = "InputScreenView" + def __init__(self, i, o, *args, **kwargs): + kwargs["message"] = self.message + kwargs["value"] = "00000000000000000000000000000000000000000000000000000000012345" + NumpadNumberInput.__init__(self, i, o, *args, **kwargs) + + def generate_views_dict(self): + d = NumpadNumberInput.generate_views_dict(self) + d.update({"InputScreenView":InputScreenView}) + return d + + +class StatusScreen(Refresher): + + arrow_x = 15 + arrow_y = 40 + handset_x = 2 + handset_y = 10 + arrow_offset = 2 + counter = 0 + number_frame = (10, 45, "-10", "-1") + number_height = 16 + number_font = ("Fixedsys62.ttf", number_height) + + def __init__(self, *args, **kwargs): + Refresher.__init__(self, self.show_status, *args, **kwargs) + self.c = Canvas(self.o) + self.prepare_number() + self.predraw_arrow() + self.predraw_cross() + self.predraw_handset() + self.status = {"number":"25250034", "accepted":True, "time":385} + + def prepare_number(self): + frame_coords = check_coordinates(self.c, self.number_frame) + nw, nh = get_bounds_for_points(cflip(frame_coords)) + self.number_c = Canvas(MockOutput(width=nw, height=nh, device_mode=self.o.device_mode)) + + def predraw_arrow(self): + aw, ah = get_bounds_for_points(graphics.arrow) + arrow_c = Canvas(MockOutput(width=aw, height=ah, device_mode=self.o.device_mode)) + arrow_c.polygon(graphics.arrow, fill="white") + self.arrow_img = arrow_c.get_image() + + def predraw_cross(self): + aw, ah = get_bounds_for_points(graphics.cross) + cross_c = Canvas(MockOutput(width=aw, height=ah, device_mode=self.o.device_mode)) + cross_c.polygon(graphics.cross, fill="white") + self.cross_img = cross_c.get_image() + + def predraw_handset(self): + handset = multiply_points(graphics.phone_handset, 2) + aw, ah = get_bounds_for_points(handset) + handset_c = Canvas(MockOutput(width=aw, height=ah, device_mode=self.o.device_mode)) + handset_c.polygon(handset, fill="white") + self.handset_img = handset_c.get_image() + + def draw_arrow(self, c, flipped=False): + coords = (self.arrow_x, self.arrow_y, self.arrow_x+self.arrow_img.width, self.arrow_y+self.arrow_img.height) + clear_coords = expand_coords(coords, self.arrow_offset) + c.clear(clear_coords) + img = self.arrow_img if not flipped else self.arrow_img.rotate(180) + c.image.paste(img, (self.arrow_x, self.arrow_y)) + + def draw_cross(self, c, flipped=False): + coords = (self.arrow_x, self.arrow_y, self.arrow_x+self.cross_img.width, self.arrow_y+self.cross_img.height) + clear_coords = expand_coords(coords, self.arrow_offset) + c.clear(clear_coords) + c.image.paste(self.cross_img, (self.arrow_x, self.arrow_y)) + + def draw_handset(self, c, flipped=False): + coords = (self.handset_x, self.handset_y, self.handset_x+self.handset_img.width, self.handset_y+self.handset_img.height) + c.image.paste(self.handset_img, (self.handset_x, self.handset_y)) + + def get_status(self): + states = ["incoming", "outgoing", "missed"] + self.status["state"] = states[self.counter] + self.counter += 1 + if self.counter == 3: self.counter = 0 + return self.status + + def draw_state(self, c, status): + if status["state"] == "incoming": + self.draw_arrow(c, flipped=True) + elif status["state"] == "outgoing": + self.draw_arrow(c) + elif status["state"] == "missed": + self.draw_cross(c) + + def draw_text_state(self, c, status): + if not status["accepted"]: + self.c.text(status["state"].capitalize(), (30, 9), font=(self.number_font[0], 16)) + else: + status["time"] = status["time"] + 1 + time_str = "{:02d}:{:02d}".format(*divmod(status["time"], 60)) + self.c.text(time_str, (30, 9), font=(self.number_font[0], 16)) + + def draw_number(self, c, status): + self.number_c.clear() + self.number_c.centered_text(status["number"], font=self.number_font) + c.image.paste(self.number_c.image, self.number_frame[:2]) + c.rectangle(self.number_frame) + + def show_status(self): + self.c.clear() + status = self.get_status() + self.draw_number(self.c, status) + self.draw_handset(self.c) + self.draw_state(self.c, status) + self.draw_text_state(self.c, status) + return self.c.get_image() + + +class PhoneApp(ZeroApp): + + menu_name = "Phone" + + def __init__(self, *args, **kwargs): + ZeroApp.__init__(self, *args, **kwargs) + self.input_screen = InputScreen(self.i, self.o) + self.insc_overlay = FunctionOverlay({"KEY_F1":"deactivate", "KEY_F2":"backspace", "KEY_ENTER":self.insc_options}, labels=["Cancel", "Menu", "Bckspc"]) + self.insc_overlay.apply_to(self.input_screen) + self.status_screen = StatusScreen(self.i, self.o, keymap={"KEY_ANSWER":self.accept_call, "KEY_HANGUP":self.reject_call}) + + def insc_options(self): + self.switch_to_status_screen() + + def get_context(self, c): + self.context = c + + def switch_to_input_screen(self, digit=None): + self.input_screen.activate() + + def switch_to_status_screen(self, digit=None): + self.status_screen.activate() + + def accept_call(self): + self.status_screen.status["accepted"] = True + + def reject_call(self): + self.status_screen.status["accepted"] = False + + def on_call(self): + self.c.request_switch() + self.show_call_status() + + def get_call_status(self): + raise NotImplementedError + + def show_call_status(self): + status = self.get_call_status() pass -def phone_status(): - data = [] - status = phone.get_status() - callerid = {} - if status["state"] != "idle": - callerid = phone.get_caller_id() - for key, value in status.iteritems(): - if value: - data.append("{}: {}".format(key, value)) - for key, value in callerid.iteritems(): - data.append("{}: {}".format(key, value)) - return data - -def call(number): - Printer("Calling {}".format(number), i, o, 0) - try: - phone.call(number) - except ATError as e: - PrettyPrinter("Calling fail! "+repr(e), i, o, 0) - logger.error("Function stopped executing") - -def call_view(): - keymap = {"KEY_ANSWER":[call, "value"]} - NumberKeypadInputLayer(i, o, "Call number", keymap, name="Phone call layer").activate() - -def status_refresher(): - Refresher(phone_status, i, o).activate() - - -def init_hardware(): - try: - global phone - phone = Phone() - modem = Modem() - phone.attach_modem(modem) - except: - deinit_hardware() - raise - -def deinit_hardware(): - phone.detach_modem() - -def wait_for_connection(): - eh = ExitHelper(i).start() - while eh.do_run() and init.running and not init.failed: - sleep(1) - eh.stop() - -def check_modem_connection(): - if init.running: - return False - elif init.finished: - return True - elif init.failed: - raise Exception("Modem connection failed!") - else: - raise Exception("Phone app init runner is in invalid state! (never ran?)") - -def init_app(input, output): - global i, o, init - i = input; o = output - i.set_maskable_callback("KEY_ANSWER", answer) - i.set_nonmaskable_callback("KEY_HANGUP", hangup) - try: - #This not good enough - either make the systemctl library system-wide or add more checks - os_call(["systemctl", "stop", "serial-getty@ttyAMA0.service"]) - except Exception as e: - logger.exception(e) - init = BackgroundRunner(init_hardware) - init.run() - -def offer_retry(counter): - do_reactivate = DialogBox("ync", i, o, message="Retry?").activate() - if do_reactivate: - PrettyPrinter("Connecting, try {}...".format(counter), i, o, 0) - init.reset() - init.run() - wait_for_connection() - callback(counter) - -def callback(counter=0): - try: - counter += 1 - status = check_modem_connection() - except: - if counter < 3: - PrettyPrinter("Modem connection failed =(", i, o) - offer_retry(counter) - else: - PrettyPrinter("Modem connection failed 3 times", i, o, 1) - else: - if not status: - PrettyPrinter("Connecting...", None, o, 0) - wait_for_connection() - callback(counter) - else: - contents = [["Status", status_refresher], - ["Call", call_view]] - Menu(contents, i, o).activate() + def on_start(self): + self.switch_to_input_screen() diff --git a/apps/phone/views.py b/apps/phone/views.py new file mode 100644 index 00000000..91378ea1 --- /dev/null +++ b/apps/phone/views.py @@ -0,0 +1,78 @@ +from ui.base_view_ui import BaseView +from ui import Canvas +from helpers import setup_logger + +logger = setup_logger(__name__, "warning") + + +class InputScreenView(BaseView): + + top_offset = 8 + value_height = 16 + value_font = ("Fixedsys62.ttf", value_height) + + def __init__(self, o, el): + BaseView.__init__(self, o, el) + self.c = Canvas(self.o) + # storage for text pagination optimisation + self.width_cache = {} + self.character_width_cache = {} + self.build_charwidth_cache() + self.prev_value_parts = [] + + def build_charwidth_cache(self): + for char in "".join(self.el.mapping.values()): + self.character_width_cache[char] = self.gtb(char, font=self.value_font)[0] + + def gtw(self, text, font): + #print("{} - {}".format(text, [self.character_width_cache[c] for c in text])) + return sum([self.character_width_cache[c] for c in text]) + + def gtb(self, text, font): + return self.c.get_text_bounds(text, font=font) + + def get_onscreen_value_parts(self, value_parts): + return value_parts[-3:] + + def get_displayed_image(self): + self.c.clear() + value = self.el.get_displayed_value() + value_parts = self.paginate_value(value, self.value_font, width=self.o.width-6, cache=self.prev_value_parts) + self.prev_value_parts = value_parts + onscreen_value_parts = self.get_onscreen_value_parts(value_parts) + for i, value_part in enumerate(onscreen_value_parts): + self.c.text(value_part, (3, self.top_offset+self.value_height*i), font=self.value_font) + return self.c.get_image() + + def paginate_value(self, value, font, width=None, cache=None): + # This function was optimised because I felt like it was kinda slow... + # Specifically, because of all the get_text_bounds calls. + # Now I'm not sure it was worth it - probably was? + width = width if width else self.o.width + value_parts = [] + # Let's optimise the counting a little bit and reuse the results of last + # pagination to speed this one up + if cache: + for part in cache[:-1]: #Except the last part since it's incomplete + if value.startswith(part): + value_parts.append(part) + value = value[len(part):] + # Now, onto checking the last part - maybe it's overflown, + # or maybe it's the first run and we haven't yet calculated the cache + # (i.e. someone supplied a long value to __init__ of the UI element) + # Let's go from the end - that will be faster for the most likely usecase. + while value: + counter = 0 + while counter < len(value): + shown_part = value[:-counter] if counter != 0 else value + remainder = value[-counter:] if counter != 0 else "" + width_of_next = self.width_cache.get(shown_part, None) + if width_of_next is None: + width_of_next = self.gtw(shown_part, font) + self.width_cache[shown_part] = width_of_next + if width_of_next < width: + break + counter += 1 + value_parts.append(shown_part) + value = remainder + return value_parts diff --git a/libs/ofono/__init__.py b/libs/ofono/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/ofono/bridge.py b/libs/ofono/bridge.py new file mode 100644 index 00000000..aa8008ec --- /dev/null +++ b/libs/ofono/bridge.py @@ -0,0 +1,212 @@ +import errno +import logging +import os +from time import sleep + +import pydbus +from dbus.mainloop.glib import DBusGMainLoop +from gi.repository import GLib + +from helpers import Singleton, setup_logger + +logger = setup_logger(__name__, 'debug') +logging.basicConfig(level=logging.DEBUG) + + +class OfonoBridge(object): + """ + Generic util class to bridge between ZPUI and ofono backend through D-Bus + """ + + def __init__(self): + self.modem_path = '/sim900_0' + self._check_default_modem() + self.connections = [] + + def _check_default_modem(self): + bus = pydbus.SystemBus() + manager = bus.get('org.ofono', '/') + modem_path = manager.GetModems()[0][0] + if modem_path != self.modem_path: + raise ValueError("Default modem should be '{}', was '{}'".format(self.modem_path, modem_path)) + + def start(self): + self.power_on_if_off() + self._init_messages() + self._listen_messages() + self._listen_calls() + + @property + def _bus(self): + """ + SystemBus().get() returns a snapshot of the current exposed methods. + Having it as a property ensures we always have the latest snapshot + (as opposed to storing it on start/after-init) + """ + return pydbus.SystemBus().get('org.ofono', self.modem_path) + + @property + def message_manager(self): + return self._get_dbus_interface('MessageManager') + + @property + def voicecall_manager(self): + return self._get_dbus_interface('VoiceCallManager') + + @property + def modem(self): + return self._get_dbus_interface('Modem') + + def _get_dbus_interface(self, name): + full_name = name if name.startswith('org.ofono') else 'org.ofono.{}'.format(name) + if full_name in self._bus.GetProperties()['Interfaces']: + return self._bus[full_name] + raise Exception("Interface '{}' wasn't found on ofono D-Bus".format(full_name)) + + def power_on_if_off(self): + if self._bus.GetProperties()["Powered"]: + logger.info("Modem already powered up !") + else: + logger.info("Powering up modem...") + try: + self._bus.SetProperty("Powered", pydbus.Variant('b', True)) + sleep(10) # Let the modem some time to initialize + except Exception as e: + logger.error("Couldn't power up the modem !") + logger.exception(e) + + def set_flight_mode(self, mode): + if self._bus.GetProperties()["Powered"]: + logger.info("Modem not powered up, not setting flight mode!") + return + logger.info("Changing the modem's flight mode to {}".format(mode)) + try: + self._bus.SetProperty("Online", pydbus.Variant('b', mode)) + except Exception as e: + logger.error("Couldn't change the flight mode state !") + logger.exception(e) + + def power_off(self): + self._bus.SetProperty("Powered", pydbus.Variant('b', False)) + + def disconnect(self): + for connection in self.connections: + pass #connection.disconnect() + #self._stop_listen_messages() + #self._stop_listen_calls() + + def send_sms(self, to, content): # todo : untested + self.message_manager.SendMessage(to, content) + logger.info("Sending message to '{}'".format(to)) + ConversationManager().on_new_message_sent(to, content) + + @staticmethod + def on_message_received(message, details, path=None, interface=None): # todo : untested + logger.info("Got message with path {}".format(path)) + ConversationManager().on_new_message_received(message, details) + + @staticmethod + def on_call_received(*args, **kwargs): # todo : untested + logger.info("Got call, args: {}, kwargs: {}".format(args, kwargs)) + #ConversationManager().on_new_message_received(message, details) + + def debug_callback(self, name): + def wrapper(*args, **kwargs): + print("{}: a {} k {}".format(name, args, kwargs)) + return wrapper + + def add_modem_property_listener(self): + pass #self.connections.append(self.modem.PropertyChanged.connect(self.debug_callback("ModemPropertyChanged"))) + #dbus.SystemBus().add_signal_receiver( + # self.debug_callback("ModemPropertyChanged"), + # 'PropertyChanged', + # 'org.ofono.Modem', + # 'org.ofono', + # '/org/ofono/Modem') + + def _listen_messages(self): + logger.info("Connecting to dbus message callbacks") + self.connections.append(self.message_manager.ImmediateMessage.connect(self.on_message_received)) + self.connections.append(self.message_manager.IncomingMessage.connect(self.on_message_received)) + self.add_modem_property_listener() + + def _listen_calls(self): + logger.info("Connecting to dbus voicecall callbacks") + self.connections.append(self.voicecall_manager.CallAdded.connect(self.on_call_received)) + + def _stop_listen_messages(self): + logger.info("Disconnecting from dbus message callbacks") + self.message_manager.disconnect() + + def _stop_listen_calls(self): + logger.info("Disconnecting from dbus voicecall callbacks") + #self.voicecall_manager.IncomingMessage.connect(self.on_call_received) + self.voicecall_manager.disconnect() + + def _init_messages(self): + self.message_manager.SetProperty("UseDeliveryReports", pydbus.Variant('b', True)) + + +class ConversationManager(Singleton): + """ + Singleton dedicated to conversations. Logs every message sent and received in a flat file + for any given phone number + """ + + def __init__(self): + super(ConversationManager, self).__init__() + self.folder = os.path.expanduser("~/.phone/sms/") # todo: store as a constant somewhere + self._create_folder() + + def _create_folder(self): + if not os.path.exists(self.folder): + try: + os.makedirs(self.folder) + except OSError as exc: # os.makedirs(exist_ok=True) does not exist for python <= 3.2 + if exc.errno == errno.EEXIST and os.path.isdir(self.folder): + pass + else: + raise + + def on_new_message_sent(self, to, content): + logger.info("Sent message to '{}'".format(to)) + self._write_log(to, self._format_log(content, from_me=True)) + + def on_new_message_received(self, content, details): + origin = details['Sender'] + logger.info("Received message from '{}'".format(origin)) + print("Message: {}".format(content)) + self._write_log(origin, self._format_log(content, from_me=True)) + + def _write_log(self, phone_number, log): + with open(self._get_log_path(phone_number), 'a+') as log_file: + log_file.write(log) + + def _get_log_path(self, phone_number): + file_name = "{}.txt".format(phone_number) + return os.path.join(self.folder, file_name) + + @staticmethod + def _format_log(content, from_me=True): + start_char = '>' if from_me else '<' + return "{prefix}\t{msg}\n".format(prefix=start_char, msg=content) + + +def main(): + DBusGMainLoop(set_as_default=True) # has to be called first + ofono = OfonoBridge() + try: + ofono.start() + mainloop = GLib.MainLoop() # todo : own thread + mainloop.run() + except KeyboardInterrupt: + logger.info("Caught CTRL-C : exiting without powering off...") + except Exception as e: + logger.error("Error while starting ofono bridge ! Powering off...") + logger.exception(e) + #ofono.power_off() + ofono.disconnect() + return ofono + +if __name__ == '__main__': + ofono = main() diff --git a/requirements.txt b/requirements.txt index 42fcd219..ad3d38c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +pydbus luma.oled luma.lcd python-nmap diff --git a/ui/__init__.py b/ui/__init__.py index c1b16f32..79f39b20 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -20,7 +20,8 @@ from scrollable_element import TextReader from loading_indicators import ProgressBar, LoadingBar, TextProgressBar, GraphicalProgressBar, CircularProgressBar, IdleDottedMessage, Throbber from numbered_menu import NumberedMenu -from canvas import Canvas, MockOutput +from canvas import Canvas, MockOutput, Rect, Image, ImageOps +from coords import check_coordinates, check_coordinate_pairs, offset_points, get_bounds_for_points, expand_coords, convert_flat_list_into_pairs, multiply_points, convert_flat_list_into_pairs from date_picker import DatePicker from time_picker import TimePicker from grid_menu import GridMenu diff --git a/ui/base_list_ui.py b/ui/base_list_ui.py index 1face2b1..267d0ca2 100644 --- a/ui/base_list_ui.py +++ b/ui/base_list_ui.py @@ -4,12 +4,12 @@ from copy import copy from time import sleep -from threading import Event from entry import Entry from canvas import Canvas from helpers import setup_logger from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin, BaseView from utils import to_be_foreground, clamp_list_index logger = setup_logger(__name__, "warning") @@ -31,7 +31,7 @@ logger.exception(e) -class BaseListUIElement(BaseUIElement): +class BaseListUIElement(BaseViewMixin, BaseUIElement): """This is a base UI element for list-like UI elements. This UI element has the ability to go into background. It's usually for the cases when an UI element can call another UI element, after the second UI element returns, @@ -47,7 +47,8 @@ class BaseListUIElement(BaseUIElement): exit_entry = ["Back", "exit"] config_key = "base_list_ui" - view_mixin = None + default_pixel_view = "SixteenPtView" + default_char_view = "TextView" def __init__(self, contents, i, o, name=None, entry_height=1, append_exit=True, exitable=True, scrolling=True, config=None, keymap=None, override_left=True): @@ -67,13 +68,11 @@ def __init__(self, contents, i, o, name=None, entry_height=1, append_exit=True, "pointer": 0 } self.reset_scrolling() - self.config = config if config is not None else global_config - self.set_view(self.config.get(self.config_key, {})) + BaseViewMixin.__init__(self, config=config) self.set_contents(contents) - self.inhibit_refresh = Event() - def set_views_dict(self): - self.views = { + def generate_views_dict(self): + return { "TextView": TextView, "EightPtView": EightPtView, "SixteenPtView": SixteenPtView, @@ -81,53 +80,6 @@ def set_views_dict(self): "PrettyGraphicalView": SixteenPtView, # Not a descriptive name - left for compatibility "SimpleGraphicalView": EightPtView # Not a descriptive name - left for compatibility } - if self.view_mixin: - class_name = self.__class__.__name__ - for view_name, view_class in self.views.items(): - if view_class.use_mixin: - name = "{}-{}".format(view_name, class_name) - logger.debug("Subclassing {} into {}".format(view_name, name)) - self.views[view_name] = type(name, (self.view_mixin, view_class), {}) - - def set_view(self, config): - view = None - self.set_views_dict() - if self.name in config.get("custom_views", {}).keys(): - view_config = config["custom_views"][self.name] - if isinstance(view_config, basestring): - if view_config not in self.views: - logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name)) - else: - view = self.views[view_config] - elif isinstance(view_config, dict): - raise NotImplementedError - # This is the part where fine-tuning views will be possible, - # once passing args&kwargs is implemented, that is - else: - logger.error( - "Custom view description can only be a string or a dictionary; is {}!".format(type(view_config))) - elif not view and "default" in config: - view_config = config["default"] - if isinstance(view_config, basestring): - if view_config not in self.views: - logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name)) - else: - view = self.views[view_config] - elif isinstance(view_config, dict): - raise NotImplementedError # Again, this is for fine-tuning - elif not view: - view = self.get_default_view() - self.view = view(self.o, self) - - def get_default_view(self): - """Decides on the view to use for UI element when config file has - no information on it.""" - if "b&w-pixel" in self.o.type: - return self.views["SixteenPtView"] - elif "char" in self.o.type: - return self.views["TextView"] - else: - raise ValueError("Unsupported display type: {}".format(repr(self.o.type))) def before_activate(self): """ @@ -324,32 +276,18 @@ def get_displayed_contents(self): """ return self.contents - def add_view_wrapper(self, wrapper): - self.view.wrappers.append(wrapper) - - @to_be_foreground - def refresh(self): - """ A placeholder to be used for BaseUIElement. """ - if self.inhibit_refresh.isSet(): - return False - self.view.refresh() - return True - # Views. -class TextView(object): +class TextView(BaseView): use_mixin = True first_displayed_entry = 0 scrolling_speed_divisor = 4 fde_increment = 1 - # Default wrapper def __init__(self, o, ui_element): - self.o = o - self.el = ui_element + BaseView.__init__(self, o, ui_element) self.entry_height = self.el.entry_height - self.wrappers = [] self.setup_scrolling() def setup_scrolling(self): @@ -477,17 +415,15 @@ def render_displayed_entry_text(self, entry_num, contents): def get_active_line_num(self): return (self.el.pointer - self.first_displayed_entry) * self.entry_height - @to_be_foreground + def get_displayed_data(self): + return self.get_displayed_text(self.el.get_displayed_contents()) + + def get_cursor_pos(self): + return (self.get_active_line_num(), 0) + def refresh(self): - logger.debug("{}: refreshed data on display".format(self.el.name)) self.fix_pointers_on_refresh() - displayed_data = self.get_displayed_text(self.el.get_displayed_contents()) - for wrapper in self.wrappers: - displayed_data = wrapper(displayed_data) - self.o.noCursor() - self.o.display_data(*displayed_data) - self.o.setCursor(self.get_active_line_num(), 0) - self.o.cursor() + self.character_refresh() class EightPtView(TextView): @@ -513,8 +449,7 @@ def refresh(self): logger.debug("{}: refreshed data on display".format(self.el.name)) self.fix_pointers_on_refresh() image = self.get_displayed_image() - for wrapper in self.wrappers: - image = wrapper(image) + image = self.execute_wrappers(image) self.o.display_image(image) def scrollbar_needed(self, contents): diff --git a/ui/base_view_ui.py b/ui/base_view_ui.py new file mode 100644 index 00000000..599fd884 --- /dev/null +++ b/ui/base_view_ui.py @@ -0,0 +1,168 @@ +from threading import Event + +from helpers import setup_logger +from utils import to_be_foreground + +logger = setup_logger(__name__, "warning") + +global_config = {} + +# Documentation building process has problems with this import +try: + import ui.config_manager as config_manager +except (ImportError, AttributeError): + pass +else: + cm = config_manager.get_ui_config_manager() + cm.set_path("ui/configs") + try: + global_config = cm.get_global_config() + except OSError as e: + logger.error("Config files not available, running under ReadTheDocs?") + logger.exception(e) + + +class BaseViewMixin(object): + + config_key = None + default_pixel_view = None + default_char_view = None + view_mixin = None + + def __init__(self, **kwargs): + config = kwargs.pop("config", None) + self.config = config if config is not None else global_config + self.set_view(self.config.get(self.config_key, {})) + self.inhibit_refresh = Event() + + def set_views_dict(self): + self.views = self.generate_views_dict() + self.add_view_mixin() + + def generate_views_dict(self): + raise NotImplementedError + + def add_view_mixin(self): + if self.view_mixin: + class_name = self.__class__.__name__ + for view_name, view_class in self.views.items(): + if view_class.use_mixin: + name = "{}-{}".format(view_name, class_name) + logger.debug("Subclassing {} into {}".format(view_name, name)) + self.views[view_name] = type(name, (self.view_mixin, view_class), {}) + + def set_view(self, config): + view = None + kwargs = {} + self.set_views_dict() + if self.name in config.get("custom_views", {}).keys(): + view_config = config["custom_views"][self.name] + if isinstance(view_config, basestring): + if view_config not in self.views: + logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name)) + else: + view = self.views[view_config] + elif isinstance(view_config, dict): + raise NotImplementedError + # This is the part where fine-tuning views will be possible, + # once passing kwargs is implemented, that is + else: + logger.error( + "Custom view description can only be a string or a dictionary; is {}!".format(type(view_config))) + elif not view and "default" in config: + view_config = config["default"] + if isinstance(view_config, basestring): + if view_config not in self.views: + logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name)) + else: + view = self.views[view_config] + elif isinstance(view_config, dict): + raise NotImplementedError # Again, this is for fine-tuning + elif not view: + view = self.get_default_view() + self.view = self.create_view_object(view, **kwargs) + + def create_view_object(self, view, **kwargs): + return view(self.o, self, **kwargs) + + def get_default_view(self): + """ + Decides on the view to use for UI element when the supplied config + has no information on it. + """ + if self.default_pixel_view and "b&w-pixel" in self.o.type: + return self.views[self.default_pixel_view] + elif self.default_char_view and "char" in self.o.type: + return self.views[self.default_char_view] + else: + raise ValueError("Unsupported display type: {}".format(repr(self.o.type))) + + def add_view_wrapper(self, wrapper): + """ + A function that allows overlays to set view wrappers, + allowing them to modify the on-screen image. + """ + self.view.wrappers.append(wrapper) + + @to_be_foreground + def refresh(self): + if self.inhibit_refresh.isSet(): + return False + self.view.refresh() + return True + + +class BaseView(object): + + def __init__(self, o, el): + self.o = o + self.el = el + self.wrappers = [] + + def execute_wrappers(self, data): + """ + For all the defined wrappers, passes the data to be displayed + (whether it's text or an image) through them. + """ + for wrapper in self.wrappers: + data = wrapper(data) + return data + + def get_displayed_image(self): + """ Needs to be implemented by child views. """ + raise NotImplementedError + + def graphical_refresh(self): + """ + A very cookie-cutter graphical refresh for a view, + might not even need to be redefined. + """ + image = self.get_displayed_image() + image = self.execute_wrappers(image) + self.o.display_image(image) + + def get_cursor_pos(self): + """ + A helper function to get cursor coordinates - if anything other than None + is returned, the cursor will be drawn in the character view. + """ + return None + + def character_refresh(self): + """ + A very cookie-cutter char display refresh for a view, + might not even need to be redefined. Draws the cursor + if ``self.get_cursor_pos()`` returns anything other than ``None``. + """ + data = self.get_displayed_data() + data = self.execute_wrappers(data) + cursor_pos = self.get_cursor_pos() + if cursor_pos is not None: + self.o.noCursor() + self.o.setCursor(*cursor_pos) + self.o.display_data(*data) + if cursor_pos is not None: + self.o.cursor() + + def refresh(self): + return self.graphical_refresh() diff --git a/ui/canvas.py b/ui/canvas.py index 3cdd92cd..db192aa2 100644 --- a/ui/canvas.py +++ b/ui/canvas.py @@ -2,7 +2,9 @@ from PIL import Image, ImageDraw, ImageOps, ImageFont -from ui.utils import is_sequence_not_string as issequence, Rect +from ui.utils import Rect + +from coords import check_coordinates, check_coordinate_pairs, convert_flat_list_into_pairs fonts_dir = "ui/fonts/" font_cache = {} @@ -128,7 +130,7 @@ def point(self, coord_pairs, **kwargs): * ``fill``: point color (default: white, as default canvas color) """ - coord_pairs = self.check_coordinate_pairs(coord_pairs) + coord_pairs = check_coordinate_pairs(self, coord_pairs) fill = kwargs.pop("fill", self.default_color) self.draw.point(coord_pairs, fill=fill, **kwargs) self.display_if_interactive() @@ -145,7 +147,7 @@ def line(self, coords, **kwargs): * ``width``: line width (default: 0, which results in a single-pixel-wide line) """ fill = kwargs.pop("fill", self.default_color) - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) self.draw.line(coords, fill=fill, **kwargs) self.display_if_interactive() @@ -169,7 +171,7 @@ def text(self, text, coords, **kwargs): fill = kwargs.pop("fill", self.default_color) font = kwargs.pop("font", self.default_font) font = self.decypher_font_reference(font) - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) if text: # Errors out on empty text self.draw.text(coords, text, fill=fill, font=font, **kwargs) self.display_if_interactive() @@ -195,7 +197,7 @@ def vertical_text(self, text, coords, **kwargs): font = kwargs.pop("font", self.default_font) charheight = kwargs.pop("charheight", None) font = self.decypher_font_reference(font) - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) char_coords = list(coords) if not charheight: # Auto-determining charheight if not available _, charheight = self.draw.textsize("H", font=font) @@ -229,7 +231,7 @@ def custom_shape_text(self, text, coords_cb, **kwargs): font = self.decypher_font_reference(font) for i, char in enumerate(text): coords = coords_cb(i, char) - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) self.draw.text(coords, char, fill=fill, font=font, **kwargs) self.display_if_interactive() @@ -245,7 +247,7 @@ def rectangle(self, coords, **kwargs): * ``outline``: outline color (default: white, as default canvas color) * ``fill``: fill color (default: None, as in, transparent) """ - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) outline = kwargs.pop("outline", self.default_color) fill = kwargs.pop("fill", None) self.draw.rectangle(coords, outline=outline, fill=fill, **kwargs) @@ -262,7 +264,7 @@ def polygon(self, coord_pairs, **kwargs): * ``outline``: outline color (default: white, as default canvas color) * ``fill``: fill color (default: None, as in, transparent) """ - coord_pairs = self.check_coordinate_pairs(coord_pairs) + coord_pairs = check_coordinate_pairs(self, coord_pairs) outline = kwargs.pop("outline", self.default_color) fill = kwargs.pop("fill", None) self.draw.polygon(coord_pairs, outline=outline, fill=fill, **kwargs) @@ -282,7 +284,7 @@ def circle(self, coords, **kwargs): assert(len(coords) == 3), "Expects three arguments - x center, y center and radius!" radius = coords[2] coords = coords[:2] - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) outline = kwargs.pop("outline", self.default_color) fill = kwargs.pop("fill", None) ellipse_coords = (coords[0]-radius, coords[1]-radius, coords[0]+radius, coords[1]+radius) @@ -301,7 +303,7 @@ def ellipse(self, coords, **kwargs): * ``outline``: outline color (default: white, as default canvas color) * ``fill``: fill color (default: None, as in, transparent) """ - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) outline = kwargs.pop("outline", self.default_color) fill = kwargs.pop("fill", None) self.draw.ellipse(coords, outline=outline, fill=fill, **kwargs) @@ -354,73 +356,10 @@ def clear(self, coords=None, fill=None): coords = (0, 0, self.width, self.height) if fill is None: fill = self.background_color - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) self.rectangle(coords, fill=fill, outline=fill) # paint the background black first self.display_if_interactive() - def check_coordinates(self, coords, check_count=True): - # type: tuple -> tuple - """ - A helper function to check and reformat coordinates supplied to - functions. Currently, accepts integer coordinates, as well as strings - - denoting offsets from opposite sides of the screen. - """ - # Checking for string offset coordinates - # First, we need to make coords into a mutable sequence - thus, a list - coords = list(coords) - for i, c in enumerate(coords): - sign = "+" - if isinstance(c, basestring): - if c.startswith("-"): - sign = "-" - c = c[1:] - assert c.isdigit(), "A numeric string expected, received: {}".format(coords[i]) - offset = int(c) - dim = self.size[i % 2] - if sign == "+": - coords[i] = dim + offset - elif sign == "-": - coords[i] = dim - offset - elif isinstance(c, float): - logger.warning("Received {} as a coordinate - pixel offsets can't be float, converting to int".format(c)) - coords[i] = int(c) - # Restoring the status-quo - coords = tuple(coords) - # Now all the coordinates should be integers - if something slipped by the checks, - # it's of type we don't process and we should raise an exception now - for c in coords: - assert isinstance(c, int), "{} not an integer or 'x' string!".format(c) - if len(coords) == 2: - return coords - elif len(coords) == 4: - x1, y1, x2, y2 = coords - # Not sure those checks make sense - #assert (x2 >= x1), "x2 ({}) is smaller than x1 ({}), rearrange?".format(x2, x1) - #assert (y2 >= y1), "y2 ({}) is smaller than y1 ({}), rearrange?".format(y2, y1) - return coords - else: - if check_count: - raise ValueError("Invalid number of coordinates!") - else: - return coords - - def check_coordinate_pairs(self, coord_pairs): - # type: tuple -> tuple - """ - A helper function to check and reformat coordinate pairs supplied to - functions. Each pair is checked by ``check_coordinates``. - """ - if not all([issequence(c) for c in coord_pairs]): - # Didn't get pairs of coordinates - converting into pairs - # But first, sanity checks - assert (len(coord_pairs) % 2 == 0), "Odd number of coordinates supplied! ({})".format(coord_pairs) - assert all([isinstance(c, (int, basestring)) for i in coord_pairs]), "Coordinates are non-uniform! ({})".format(coord_pairs) - coord_pairs = convert_flat_list_into_pairs(coord_pairs) - coord_pairs = list(coord_pairs) - for i, coord_pair in enumerate(coord_pairs): - coord_pairs[i] = self.check_coordinates(coord_pair) - return tuple(coord_pairs) - def centered_text(self, text, cw=None, ch=None, font=None): # type: str -> None """ @@ -436,7 +375,7 @@ def centered_text(self, text, cw=None, ch=None, font=None): self.display_if_interactive() def get_text_bounds(self, text, font=None): - # type: str -> Rect + # type: str -> tuple """ Returns the dimensions for a given text. If you use a non-default font, pass it as ``font``. @@ -474,7 +413,7 @@ def invert_rect(self, coords): highlighting a part of the image, for example. """ - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) image_subset = self.image.crop(coords) if image_subset.mode == "1": @@ -553,9 +492,3 @@ def __init__(self, width=128, height=64, type=None, device_mode='1'): def display_image(self, *args): return True - -def convert_flat_list_into_pairs(l): - pl = [] - for i in range(len(l)/2): - pl.append((l[i*2], l[i*2+1])) - return pl diff --git a/ui/char_input.py b/ui/char_input.py index c7d5f598..7e4d041a 100644 --- a/ui/char_input.py +++ b/ui/char_input.py @@ -7,9 +7,10 @@ from utils import to_be_foreground from canvas import Canvas from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin, BaseView -class CharArrowKeysInput(BaseUIElement): +class CharArrowKeysInput(BaseViewMixin, BaseUIElement): """ Implements a character input dialog which allows to input a character string using arrow keys to scroll through characters """ @@ -37,7 +38,11 @@ class CharArrowKeysInput(BaseUIElement): cancel_flag = False charmap = "" - def __init__(self, i, o, message="Value:", value="", allowed_chars=['][S', '][c', '][C', '][s', '][n'], name="CharArrowKeysInput", initial_value=""): + config_key = "char_arrow_keys_input" + default_pixel_view = "GraphicalView" + default_char_view = "TextView" + + def __init__(self, i, o, message="Value:", value="", allowed_chars=['][S', '][c', '][C', '][s', '][n'], name="CharArrowKeysInput", initial_value="", config={}): """Initialises the CharArrowKeysInput object. Args: @@ -76,16 +81,12 @@ def __init__(self, i, o, message="Value:", value="", allowed_chars=['][S', '][c self.char_indices = [] #Fixes a bug with char_indices remaining from previous input ( 0_0 ) for char in self.value: self.char_indices.append(self.charmap.index(char)) - self.set_view() + BaseViewMixin.__init__(self, config=config) - def set_view(self): - if "b&w-pixel" in self.o.type: - view_class = GraphicalView - elif "char" in self.o.type: - view_class = TextView - else: - raise ValueError("Unsupported display type: {}".format(repr(self.o.type))) - self.view = view_class(self.o, self) + + def generate_views_dict(self): + return {"GraphicalView":GraphicalView, + "TextView": TextView} def get_return_value(self): if self.cancel_flag: @@ -139,10 +140,8 @@ def move_down(self): def move_right(self): """Moves cursor to the next element. """ self.check_for_backspace() + self.view.fix_pointers_before_refresh() self.position += 1 - if self.view.last_displayed_char < self.position: #Went too far to the part of the value that isn't currently displayed - self.view.last_displayed_char = self.position - self.view.first_displayed_char = self.position - self.o.cols self.refresh() @to_be_foreground @@ -152,10 +151,8 @@ def move_left(self): if self.position == 0: self.exit() return + self.view.fix_pointers_before_refresh() self.position -= 1 - if self.view.first_displayed_char > self.position: #Went too far back to the part that's not currently displayed - self.view.first_displayed_char = self.position - self.view.last_displayed_char = self.position + self.o.cols self.refresh() @to_be_foreground @@ -200,17 +197,23 @@ def refresh(self): logger.debug("{}: refreshed data on display".format(self.name)) -class TextView(object): +class TextView(BaseView): - last_displayed_char = 0 first_displayed_char = 0 def __init__(self, o, el): - self.o = o - self.el = el - self.last_displayed_char = self.o.cols + BaseView.__init__(self, o, el) + + def fix_pointers_before_refresh(self): + if self.first_displayed_char+self.o.cols < self.el.position: #Went too far to the part of the value that isn't currently displayed + self.first_displayed_char = self.el.position - self.o.cols + if self.first_displayed_char > self.el.position: #Went too far back to the part that's not currently displayed + self.first_displayed_char = self.el.position def get_displayed_data(self): + return self.convert_chars_to_hd44780_charset( *self.get_displayed_text() ) + + def get_displayed_text(self): """ Formats the value and the message to show it on the screen, then returns a list that can be directly used by o.display_data. @@ -227,21 +230,20 @@ def convert_chars_to_hd44780_charset(self, message, value): value = value.replace(' ', chr(255)) #Displaying all spaces as black boxes return message, value + def get_cursor_pos(self): + return (1, self.el.position-self.first_displayed_char) + def refresh(self): - self.o.noCursor() - #self.o.cursor()# Only needed for testing TextView on luma.oled - displayed_data = self.convert_chars_to_hd44780_charset( *self.get_displayed_data() ) - self.o.display_data(*displayed_data) - self.o.cursor() + return self.character_refresh() class GraphicalView(TextView): - def get_image(self): + def get_displayed_image(self): c = Canvas(self.o) #Getting displayed data, drawing it - lines = self.get_displayed_data() + lines = self.get_displayed_text() for i, line in enumerate(lines): y = (i*self.o.char_height - 1) if i != 0 else 0 c.text(line, (2, y)) @@ -261,4 +263,4 @@ def get_image(self): return c.get_image() def refresh(self): - self.o.display_image(self.get_image()) + return self.graphical_refresh() diff --git a/ui/coords.py b/ui/coords.py new file mode 100644 index 00000000..6c13f13b --- /dev/null +++ b/ui/coords.py @@ -0,0 +1,120 @@ +from ui.utils import is_sequence_not_string as issequence + +def check_coordinates(canvas, coords, check_count=True): + # type: tuple -> tuple + """ + A helper function to check and reformat coordinates supplied to + functions. Currently, accepts integer coordinates, as well as strings + - denoting offsets from opposite sides of the screen. + """ + # Checking for string offset coordinates + # First, we need to make coords into a mutable sequence - thus, a list + coords = list(coords) + for i, c in enumerate(coords): + sign = "+" + if isinstance(c, basestring): + if c.startswith("-"): + sign = "-" + c = c[1:] + assert c.isdigit(), "A numeric string expected, received: {}".format(coords[i]) + offset = int(c) + dim = canvas.size[i % 2] + if sign == "+": + coords[i] = dim + offset + elif sign == "-": + coords[i] = dim - offset + elif isinstance(c, float): + logger.warning("Received {} as a coordinate - pixel offsets can't be float, converting to int".format(c)) + coords[i] = int(c) + # Restoring the status-quo + coords = tuple(coords) + # Now all the coordinates should be integers - if something slipped by the checks, + # it's of type we don't process and we should raise an exception now + for c in coords: + assert isinstance(c, int), "{} not an integer or 'x' string!".format(c) + if len(coords) == 2: + return coords + elif len(coords) == 4: + x1, y1, x2, y2 = coords + # Not sure those checks make sense + #assert (x2 >= x1), "x2 ({}) is smaller than x1 ({}), rearrange?".format(x2, x1) + #assert (y2 >= y1), "y2 ({}) is smaller than y1 ({}), rearrange?".format(y2, y1) + return coords + else: + if check_count: + raise ValueError("Invalid number of coordinates!") + else: + return coords + +def check_coordinate_pairs(canvas, coord_pairs): + # type: tuple -> tuple + """ + A helper function to check and reformat coordinate pairs supplied to + functions. Each pair is checked by ``check_coordinates``. + """ + if not all([issequence(c) for c in coord_pairs]): + # Didn't get pairs of coordinates - converting into pairs + # But first, sanity checks + assert (len(coord_pairs) % 2 == 0), "Odd number of coordinates supplied! ({})".format(coord_pairs) + assert all([isinstance(c, (int, basestring)) for i in coord_pairs]), "Coordinates are non-uniform! ({})".format(coord_pairs) + coord_pairs = convert_flat_list_into_pairs(coord_pairs) + coord_pairs = list(coord_pairs) + for i, coord_pair in enumerate(coord_pairs): + coord_pairs[i] = check_coordinates(canvas, coord_pair) + return tuple(coord_pairs) + +def offset_points(points, offset): + """ + Given a list/tuple of points and a two-integer offset tuple + (``(x, y)``), will offset all the points by that ``x`` and ``y`` + and return a tuple. + """ + return tuple([(p[0]+offset[0], p[1]+offset[1]) for p in points]) + +def multiply_points(points, mul): + """ + Given a list/tuple of points and an integer/float multiplier, + will multiply all the point coordinates (both x and y) + and return them. + """ + return tuple([(int(p[0]*mul), int(p[1]*mul)) for p in points]) + +def expand_coords(coords, expand): + """ + Expands 4 coordinate values by either a single number or a tuple - + depends on what you supply to it. + """ + assert len(coords) == 4, "Need to supply 4 numbers as 'coords' - received {}".format(coords) + if isinstance(expand, int): + c = coords; e = expand + return (c[0]-e, c[1]-e, c[2]+e, c[3]+e) + elif isinstance(expand, (tuple, list)): + assert len(expand) == 4, "Need to supply 4 numbers as 'expand' if iterable - received {}".format(coords) + c = coords; e = expand + return (c[0]-e[0], c[1]-e[1], c[2]+e[2], c[3]+e[3]) + else: + raise ValueError("Can't do anything with {} as 'expand'".format(expand)) + +def get_bounds_for_points(points): + """ + Given a list/tuple of points (assumed to form a polygon), will return two numbers: + + * ``x``: width of the polygon made from the points (difference between rightmost and leftmost + 1) + * ``y``, height of the polygon made from the points (difference between topmost and bottommost + 1) + """ + lx = [p[0] for p in points] + ly = [p[1] for p in points] + dx = max(lx)-min(lx) + 1 + dy = max(ly)-min(ly) + 1 + return dx, dy + +def convert_flat_list_into_pairs(l): + """ + Given an iterable of elements, will pair them together and return + a tuple of two-element tuples. If the list has an odd number + of elements, will silently reject the last element. + """ + pl = [] + for i in range(len(l)/2): + pl.append((l[i*2], l[i*2+1])) + return tuple(pl) diff --git a/ui/dialog.py b/ui/dialog.py index 9e19aa96..d037c224 100644 --- a/ui/dialog.py +++ b/ui/dialog.py @@ -3,11 +3,12 @@ from funcs import format_for_screen as ffs from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin, BaseView from canvas import Canvas logger = setup_logger(__name__, "info") -class DialogBox(BaseUIElement): +class DialogBox(BaseViewMixin, BaseUIElement): """Implements a dialog box with given values (or some default ones if chosen).""" value_selected = False @@ -15,7 +16,11 @@ class DialogBox(BaseUIElement): default_options = {"y":["Yes", True], 'n':["No", False], 'c':["Cancel", None]} start_option = 0 - def __init__(self, values, i, o, message="Are you sure?", name="DialogBox"): + config_key = "dialog" + default_pixel_view = "GraphicalView" + default_char_view = "TextView" + + def __init__(self, values, i, o, message="Are you sure?", name="DialogBox", config={}): """Initialises the DialogBox object. Args: @@ -51,16 +56,12 @@ def __init__(self, values, i, o, message="Are you sure?", name="DialogBox"): values[i] = self.default_options[value] self.values = values self.message = message - self.set_view() + BaseViewMixin.__init__(self, config=config) - def set_view(self): - if "b&w-pixel" in self.o.type: - view_class = GraphicalView - elif "char" in self.o.type: - view_class = TextView - else: - raise ValueError("Unsupported display type: {}".format(repr(self.o.type))) - self.view = view_class(self.o, self) + def generate_views_dict(self): + return { + "TextView": TextView, + "GraphicalView": GraphicalView} def set_start_option(self, option_number): """ @@ -109,15 +110,11 @@ def accept_value(self): self.value_selected = True self.deactivate() - def refresh(self): - self.view.refresh() - -class TextView(object): +class TextView(BaseView): def __init__(self, o, el): - self.o = o - self.el = el + BaseView.__init__(self, o, el) self.process_values() def process_values(self): @@ -134,15 +131,18 @@ def process_values(self): self.positions.append(current_position) current_position += len(label) + 1 + def get_cursor_pos(self): + return (1, self.positions[self.el.selected_option]) + + def get_displayed_data(self): + return [self.el.message, self.displayed_label] + def refresh(self): - self.o.noCursor() - self.o.setCursor(1, self.positions[self.el.selected_option]) - self.o.display_data(self.el.message, self.displayed_label) - self.o.cursor() + return self.character_refresh() class GraphicalView(TextView): - def get_image(self): + def get_displayed_image(self): c = Canvas(self.o) #Drawing text chunk_y = 0 @@ -169,4 +169,4 @@ def get_image(self): return c.get_image() def refresh(self): - self.o.display_image(self.get_image()) + return self.graphical_refresh() diff --git a/ui/number_input.py b/ui/number_input.py index 323a98e1..38491b5d 100644 --- a/ui/number_input.py +++ b/ui/number_input.py @@ -5,8 +5,9 @@ from utils import to_be_foreground from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin, BaseView -class IntegerAdjustInput(BaseUIElement): +class IntegerAdjustInput(BaseViewMixin, BaseUIElement): """Implements a simple number input dialog which allows you to increment/decrement a number using which can be used to navigate through your application, output a list of values or select actions to perform. Is one of the most used elements, used both in system core and in most of the applications. Attributes: @@ -23,6 +24,8 @@ class IntegerAdjustInput(BaseUIElement): initial_number = 0 number = 0 selected_number = None + config_key = "integer_adjust" + default_char_view = "TextView" def __init__(self, number, i, o, message="Pick a number:", interval=1, name="IntegerAdjustInput", mode="normal", max=None, min=None): """Initialises the IntegerAdjustInput object. @@ -53,16 +56,27 @@ def __init__(self, number, i, o, message="Pick a number:", interval=1, name="Int self.message = message self.mode = mode self.interval = interval + BaseViewMixin.__init__(self) def get_return_value(self): return self.selected_number + def generate_views_dict(self): + return { + "TextView": TextView} + def idle_loop(self): sleep(0.1) + def get_displayed_number(self): + if self.mode == "hex": + return hex(self.number) + else: + return str(self.number) + def print_number(self): """ A debug method. Useful for hooking up to an input event so that you can see current number value. """ - logger.info(self.number) + logger.info(self.get_displayed_number()) def decrement(self, multiplier=1): """Decrements the number by selected ``interval``""" @@ -117,15 +131,13 @@ def clamp(self): if self.max is not None and self.number > self.max: self.number = self.max + +class TextView(BaseView): + def get_displayed_data(self): - if self.mode == "hex": - number_str = hex(self.number) - else: - number_str = str(self.number) + number_str = self.el.get_displayed_number() number_str = number_str.rjust(self.o.cols) - return [self.message, number_str] + return [self.el.message, number_str] - @to_be_foreground def refresh(self): - logger.debug("{0}: refreshed data on display".format(self.name)) - self.o.display_data(*self.get_displayed_data()) + return self.character_refresh() diff --git a/ui/numpad_input.py b/ui/numpad_input.py index 1076f7fa..861eac4b 100644 --- a/ui/numpad_input.py +++ b/ui/numpad_input.py @@ -7,6 +7,7 @@ KEY_PRESSED, KEY_RELEASED, KEY_HELD from utils import to_be_foreground, check_value_lock from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin, BaseView logger = setup_logger(__name__, "warning") @@ -35,7 +36,7 @@ def wrapper(self, *args, **kwargs): return decorator -class NumpadCharInput(BaseUIElement): +class NumpadCharInput(BaseViewMixin, BaseUIElement): """Implements a character input UI element for a numeric keypad, allowing to translate numbers into characters. Attributes: @@ -62,11 +63,11 @@ class NumpadCharInput(BaseUIElement): held_mapping = {str(i):str(i) for i in range(10)} action_keys = { - "KEY_ENTER":"accept_value", "KEY_F1":"deactivate", - "KEY_LEFT":"deactivate_if_first", - "KEY_RIGHT":"skip", "KEY_F2":"backspace", + "KEY_ENTER":"accept_value", + "KEY_LEFT":"deactivate_if_first", + "KEY_RIGHT":"skip" } bottom_row_buttons = ["Cancel", "OK", "Erase"] @@ -81,7 +82,10 @@ class NumpadCharInput(BaseUIElement): current_letter_num = 0 __locked_name__ = None - def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", mapping=None): + config_key = "dialog" + default_char_view = "TextView" + + def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", mapping=None, config={}): """Initialises the NumpadCharInput object. Args: @@ -92,17 +96,21 @@ def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", map * ``mapping``: alternative key-to-characters mapping to use """ + self.action_keys = copy(self.action_keys) BaseUIElement.__init__(self, i, o, name) self.message = message self.value = value self.position = len(self.value) - self.action_keys = copy(self.action_keys) if mapping is not None: self.mapping = copy(mapping) else: self.mapping = copy(self.default_mapping) self.value_lock = Lock() self.value_accepted = False + BaseViewMixin.__init__(self, config=config) + + def generate_views_dict(self): + return {"TextView": TextView} def before_foreground(self): self.value_accepted = False @@ -111,10 +119,6 @@ def before_foreground(self): def before_activate(self): self.o.cursor() - @property - def is_active(self): - return self.in_foreground - def after_activate(self): self.o.noCursor() self.i.remove_streaming() @@ -320,36 +324,6 @@ def get_displayed_value(self): """ return self.value - def get_displayed_data(self): - """Experimental: not meant for 2x16 displays - - Formats the value and the message to show it on the screen, then returns a list that can be directly used by o.display_data""" - displayed_data = [self.message] - screen_rows = self.o.rows - screen_cols = self.o.cols - static_line_count = 2 #One for message, another for context key labels - value = self.get_displayed_value() - lines_taken_by_value = (len(value) / (screen_cols)) + 1 - for line_i in range(lines_taken_by_value): - displayed_data.append(value[(line_i*screen_cols):][:screen_cols]) - empty_line_count = screen_rows - (static_line_count + lines_taken_by_value) - for _ in range(empty_line_count): - displayed_data.append("") #Just empty line - third_line_length = screen_cols/3 - button_labels = [button.center(third_line_length) for button in self.bottom_row_buttons] - last_line = "".join(button_labels) - displayed_data.append(last_line) - return displayed_data - - @to_be_foreground - def refresh(self): - """Function that is called each time data has to be output on display""" - cursor_y, cursor_x = divmod(self.position, self.o.cols) - cursor_y += 1 - self.o.setCursor(cursor_y, cursor_x) - self.o.display_data(*self.get_displayed_data()) - logger.debug("{}: refreshed data on display".format(self.name)) - #Debug-related functions. def print_value(self): @@ -449,3 +423,36 @@ class NumpadKeyboardInput(NumpadCharInput): default_mapping[c] += c default_mapping["SPACE"] = " " + +# Views + +class TextView(BaseView): + def get_displayed_data(self): + """Experimental: not meant for 2x16 displays + + Formats the value and the message to show it on the screen, then returns a list that can be directly used by o.display_data""" + displayed_data = [self.el.message] + screen_rows = self.o.rows + screen_cols = self.o.cols + static_line_count = 2 #One for message, another for context key labels + value = self.el.get_displayed_value() + lines_taken_by_value = (len(value) / (screen_cols)) + 1 + for line_i in range(lines_taken_by_value): + displayed_data.append(value[(line_i*screen_cols):][:screen_cols]) + empty_line_count = screen_rows - (static_line_count + lines_taken_by_value) + for _ in range(empty_line_count): + displayed_data.append("") #Just empty line + third_line_length = screen_cols/3 + button_labels = [button.center(third_line_length) for button in self.el.bottom_row_buttons] + last_line = "".join(button_labels) + displayed_data.append(last_line) + return displayed_data + + def get_cursor_pos(self): + cursor_y, cursor_x = divmod(self.el.position, self.o.cols) + cursor_y += 1 + return (cursor_y, cursor_x) + + def refresh(self): + """Function that is called each time data has to be output on display""" + return self.character_refresh() diff --git a/ui/overlays.py b/ui/overlays.py index 5282d94a..c32322cc 100644 --- a/ui/overlays.py +++ b/ui/overlays.py @@ -59,20 +59,13 @@ def __init__(self, callback, key = "KEY_F5", **kwargs): BaseOverlayWithTimeout.__init__(self, **kwargs) def apply_to(self, ui_el): - self.wrap_generate_keymap(ui_el) + self.update_keymap(ui_el) self.wrap_view(ui_el) BaseOverlayWithTimeout.apply_to(self, ui_el) - def wrap_generate_keymap(self, ui_el): - generate_keymap = ui_el.generate_keymap - @wraps(generate_keymap) - def wrapper(*args, **kwargs): - keymap = generate_keymap(*args, **kwargs) - key, callback = self.get_key_and_callback() - keymap[key] = ui_el.process_callback(callback) - return keymap - ui_el.generate_keymap = wrapper - ui_el.set_default_keymap() + def update_keymap(self, ui_el): + key, callback = self.get_key_and_callback() + ui_el.update_keymap({key:callback}) def wrap_view(self, ui_el): def wrapper(image): @@ -136,6 +129,9 @@ def __init__(self, keymap, labels=["Exit", "Options"], **kwargs): self.labels = labels BaseOverlayWithTimeout.__init__(self, **kwargs) + def update_keymap(self, ui_el): + ui_el.update_keymap(self.keymap) + def wrap_generate_keymap(self, ui_el): generate_keymap = ui_el.generate_keymap @wraps(generate_keymap) @@ -147,7 +143,7 @@ def wrapper(*args, **kwargs): ui_el.set_default_keymap() def draw_icon(self, c): - half_line_length = c.o.cols/self.num_keys + half_line_length = c.o.cols/len(self.labels) last_line = "".join([label.center(half_line_length) for label in self.labels]) c.clear((self.right_offset, str(-self.bottom_offset), c.width-self.right_offset, c.height)) c.text(last_line, (self.right_offset, str(-self.bottom_offset)), font=self.font) diff --git a/ui/tests/test_canvas.py b/ui/tests/test_canvas.py index aea66776..515d86f8 100644 --- a/ui/tests/test_canvas.py +++ b/ui/tests/test_canvas.py @@ -51,18 +51,6 @@ def test_base_image(self): assert(c.image == i) assert(c.size == (w, h)) - def test_coords_filtering(self): - """tests whether the coordinate filtering works""" - w = 128 - h = 64 - c = Canvas(get_mock_output(width=w, height=h), name=c_name) - assert (c.check_coordinates((0, 1)) == (0, 1)) - assert (c.check_coordinates(("-2", "-3")) == (w-2, h-3)) - assert (c.check_coordinates((0, 1, 2, 3)) == (0, 1, 2, 3)) - assert (c.check_coordinates((0, 1, "-2", "-3")) == (0, 1, w-2, h-3)) - assert (c.check_coordinates(("-0", "-1", "-2", "-3")) == (w, h-1, w-2, h-3)) - assert (c.check_coordinates(("-0", "1", "-2", "-3")) == (w, h+1, w-2, h-3)) - def test_howto_example_drawing_basics(self): """tests the first canvas example from howto""" test_image = get_image("canvas_1.png") diff --git a/ui/tests/test_coords.py b/ui/tests/test_coords.py new file mode 100644 index 00000000..a7058229 --- /dev/null +++ b/ui/tests/test_coords.py @@ -0,0 +1,84 @@ +"""tests for coords""" +import os +import unittest + +from mock import patch, Mock + +try: + from ui import Canvas, coords +except ImportError: + print("Absolute imports failed, trying relative imports") + os.sys.path.append(os.path.dirname(os.path.abspath('.'))) + # Store original __import__ + orig_import = __import__ + + def import_mock(name, *args): + if name in ['helpers']: + return Mock() + elif name == 'ui.utils': + import utils + return utils + return orig_import(name, *args) + + with patch('__builtin__.__import__', side_effect=import_mock): + from canvas import Canvas + import coords + + +def get_mock_output(width=128, height=64, mode="1"): + m = Mock() + m.configure_mock(width=width, height=height, device_mode=mode, type=["b&w-pixel"]) + return m + + +class TestCanvas(unittest.TestCase): + """Tests the coords functions""" + + def test_coords_filtering(self): + """tests whether the coordinate filtering works""" + w = 128 + h = 64 + c = Canvas(get_mock_output(width=w, height=h)) + assert (coords.check_coordinates(c, (0, 1)) == (0, 1)) + assert (coords.check_coordinates(c, ("-2", "-3")) == (w-2, h-3)) + assert (coords.check_coordinates(c, (0, 1, 2, 3)) == (0, 1, 2, 3)) + assert (coords.check_coordinates(c, (0, 1, "-2", "-3")) == (0, 1, w-2, h-3)) + assert (coords.check_coordinates(c, ("-0", "-1", "-2", "-3")) == (w, h-1, w-2, h-3)) + assert (coords.check_coordinates(c, ("-0", "1", "-2", "-3")) == (w, h+1, w-2, h-3)) + + def test_cflip(self): + """ Tests convert_flat_list_into_pairs """ + cflip = coords.convert_flat_list_into_pairs + assert cflip((1, 2, 3, 4)) == ((1, 2), (3, 4)) + assert cflip((1, 2, 3, 4, 5, 6)) == ((1, 2), (3, 4), (5, 6)) + assert cflip((1, 2, 3, 4, 5)) == ((1, 2), (3, 4)) + + def test_get_bounds_for_points(self): + """ Tests convert_flat_list_into_pairs """ + gbfp = coords.get_bounds_for_points + assert gbfp(((1, 2), (3, 4), (5, 6))) == (5, 5) + assert gbfp(((1, 2), (3, 4))) == (3, 3) + + def test_expand_coords(self): + """ Tests expand_coords """ + ec = coords.expand_coords + assert ec((1, 2, 3, 4), 1) == (0, 1, 4, 5) + assert ec((0, 10, 20, 30), 1) == (-1, 9, 21, 31) + + def test_multiply_points(self): + """ Tests multiply_points """ + mp = coords.multiply_points + assert mp(((1, 2), (3, 4)), 1) == ((1, 2), (3, 4)) + assert mp(((1, 2), (3, 4)), 2) == ((2, 4), (6, 8)) + assert mp(((1, 2), (3, 4)), 2.5) == ((2, 5), (7, 10)) + + def test_offset_points(self): + """ Tests offset_points """ + op = coords.offset_points + assert op(((1, 2), (3, 4)), (10, 10)) == ((11, 12), (13, 14)) + assert op(((1, 2), (3, 4)), (10, 20)) == ((11, 22), (13, 24)) + assert op(((1, 2), (3, 4)), (20, 20)) == ((21, 22), (23, 24)) + + +if __name__ == '__main__': + unittest.main() diff --git a/ui/tests/test_numpad_input.py b/ui/tests/test_numpad_input.py index 80760730..84e40eae 100644 --- a/ui/tests/test_numpad_input.py +++ b/ui/tests/test_numpad_input.py @@ -28,12 +28,17 @@ def import_mock(name, *args): def get_mock_input(): return Mock(maskable_keymap=["KEY_LEFT"]) - def get_mock_output(rows=8, cols=21): m = Mock() m.configure_mock(rows=rows, cols=cols, type=["char"]) return m + +def get_mock_graphical_output(width=128, height=64, mode="1", **kwargs): + m = get_mock_output(**kwargs) + m.configure_mock(width=width, height=height, device_mode=mode, type=["char", "b&w-pixel"]) + return m + def execute_key_sequence(ni, key_sequence): for key in key_sequence: key = "KEY_{}".format(key) @@ -42,7 +47,6 @@ def execute_key_sequence(ni, key_sequence): else: ni.process_streaming_keycode(key) - ni_name = "Test NumpadCharInput" @@ -56,19 +60,19 @@ def test_constructor(self): ni = self.cls(get_mock_input(), get_mock_output(), name=ni_name) self.assertIsNotNone(ni) - def test_action_keys_leakage(self): - """tests whether the action key settings of one NumpadCharInput leaks into another""" + def test_mapping_leakage(self): + """tests whether the mapping of one NumpadCharInput leaks into another""" i = get_mock_input() o = get_mock_output() i1 = self.cls(i, o, name=ni_name + "1") - i1.action_keys["F1"] = "accept_value" + i1.mapping["0"] = "a" i2 = self.cls(i, o, name=ni_name + "2") - i1.action_keys["F1"] = "accept_value" - i2.action_keys["ENTER"] = "deactivate" + i1.mapping["0"] = "s" + i2.mapping["1"] = "d" i3 = self.cls(i, o, name=ni_name + "3") - assert (i1.action_keys != i2.action_keys) - assert (i2.action_keys != i3.action_keys) - assert (i1.action_keys != i3.action_keys) + assert (i1.mapping != i2.mapping) + assert (i2.mapping != i3.mapping) + assert (i1.mapping != i3.mapping) def test_f1_left_returns_none(self): ni = self.cls(get_mock_input(), get_mock_output(), name=ni_name) @@ -144,6 +148,25 @@ def scenario(): assert o.display_data.call_count == 1 #One in to_foreground assert o.display_data.call_args[0] == ('Test:', '', '', '', '', '', '', ' Cancel OK Erase ') + def test_shows_data_on_graphical_screen(self): + """Tests whether the NumpadCharInput outputs data on a graphical screen when it's ran""" + i = get_mock_input() + o = get_mock_graphical_output() + ni = self.cls(i, o, message="Test:", name=ni_name) + + def scenario(): + ni.deactivate() + + with patch.object(ni, 'idle_loop', side_effect=scenario) as p: + ni.activate() + #The scenario should only be called once + assert ni.idle_loop.called + assert ni.idle_loop.call_count == 1 + + assert o.display_data.called + assert o.display_data.call_count == 1 #One in to_foreground + assert o.display_data.call_args[0] == ('Test:', '', '', '', '', '', '', ' Cancel OK Erase ') + class TestNumpadPasswordInput(TestNumpadCharInput): """test NumpadPasswordInput class"""