diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 5fed50b9..1a0c37e4 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -17,4 +17,8 @@ e4787f7ae96e2a6f11838dcd5591b58ab69fc55a cb040bf0656ab6b3c019fadc6adf98c7d4ba01ea f7addad546672815db9293f772db71c57521f8e8 fbeec4c4322b7a1f8dc4cd82ac3c10e6c313901a +# move langformat +7a3d17b934d739118fb074f0f572d1e30651117d +# moving widgets under Widgets dummy class +e94b09e4f103dfade9d6fffc095507b58c836bce diff --git a/README2.md b/README2.md new file mode 100644 index 00000000..2b80cc8e --- /dev/null +++ b/README2.md @@ -0,0 +1,40 @@ + + + +

+ + + + pysimplesql logo + +

+ +Write data-driven desktop apps fast! Lightweight Python library supports SQLite, MySQL/MariaDB, PostgreSQL, Flatfile CSV, SQL Server and MS Access. Uses PySimpleGUI layouts. + +--- + +![gif here](https://) + +**[Features](#features)** - **[Requirements](#requirements)** - **[Installation](#installation)** - **[Quick usage](#quick-usage)** + +## Features + +- [**Test**](https://) +- Design user interfaces with automatic relationship handling + +## Requirements + +Dependencies here, along with link to sqldriver specific page + +## Installation + +- **Unix/macOS**: `python3 -m pip install pysimplesql` +- **Windows**: `py -m pip install pysimplesql` + +## Quick usage + +``` +Ultra simple example here +``` + +See the [Usage](https://pysimplesql.github.io/pysimplesql/) section of the docs for more examples! \ No newline at end of file diff --git a/examples/SQLite_examples/checkbox_behavior.py b/examples/SQLite_examples/checkbox_behavior.py new file mode 100644 index 00000000..662785de --- /dev/null +++ b/examples/SQLite_examples/checkbox_behavior.py @@ -0,0 +1,80 @@ +# fmt: off +import logging +import PySimpleGUI as sg + +sg.change_look_and_feel("SystemDefaultForReal") +sg.set_options(font=("Roboto", 11)) # Set the font and font size for the table + +import pysimplesql as ss # noqa: E402 + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) # <=== Set the logging level here (NOTSET,DEBUG,INFO,WARNING,ERROR,CRITICAL) + +sql = """ +CREATE TABLE checkboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bool_none BOOLEAN, + bool_true BOOLEAN Default True, + bool_false BOOLEAN Default False, + int_none INTEGER, + int_true INTEGER Default 1, + int_false INTEGER Default 0, + text_none TEXT, + text_true TEXT Default "True", + text_false TEXT Default "False" +); + +INSERT INTO checkboxes (bool_none, bool_true, bool_false, int_none, int_true, int_false, text_none, text_true, text_false) +VALUES (NULL,True,False,NULL,1,0,NULL,"True","False"); +""" + +# ------------------------- +# CREATE PYSIMPLEGUI LAYOUT +# ------------------------- +# Create a table heading object +headings = ss.TableHeadings(sort_enable=True, edit_enable=True) + +# Add columns to the table heading +headings.add_column('id', 'id', width=5) + +columns = ['bool_none', 'bool_true', 'bool_false', 'int_none', 'int_true', 'int_false', 'text_none', 'text_true', 'text_false'] + +for col in columns: + headings.add_column(col, col, width=8) + +fields = [] +for col in columns: + fields.append([ss.field(f'checkboxes.{col}', sg.Checkbox, size=(20, 10), label={col})]) + +layout = [ + [sg.Text('This test shows pysimplesql checkbox behavior.')], + [sg.Text('Each column is labeled as type: bool=BOOLEAN, int=INTEGER, text=TEXT')], + [sg.Text("And the DEFAULT set for new records, no default set, True,1,'True', or False,0,'False'")], + [ss.selector('checkboxes', sg.Table, num_rows=10, headings=headings, row_height=25)], + [ss.actions('checkboxes', edit_protect=False)], + fields, +] + +win = sg.Window('Checkbox Test', layout, finalize=True) +driver = ss.Driver.sqlite(":memory:", sql_commands=sql) +# Here is the magic! +frm = ss.Form( + driver, + bind_window=win, + live_update=True # this updates the `Selector`, sg.Table as we type in fields! + ) + +# --------- +# MAIN LOOP +# --------- +while True: + event, values = win.read() + + if event == sg.WIN_CLOSED or event == 'Exit': + frm.close() # <= ensures proper closing of the sqlite database and runs a database optimization + win.close() + break + elif ss.process_events(event, values): # <=== let PySimpleSQL process its own events! Simple! + logger.info(f'PySimpleDB event handler handled the event {event}!') + else: + logger.info(f'This event ({event}) is not yet handled.') diff --git a/examples/SQLite_examples/orders.py b/examples/SQLite_examples/orders.py new file mode 100644 index 00000000..ae6bf990 --- /dev/null +++ b/examples/SQLite_examples/orders.py @@ -0,0 +1,374 @@ +import logging + +import PySimpleGUI as sg +import pysimplesql as ss + +# PySimpleGUI options +# ----------------------------- +sg.change_look_and_feel("SystemDefaultForReal") +sg.set_options(font=("Arial", 11), dpi_awareness=True) + +# Setup Logger +# ----------------------------- +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + +# Use the `xpnative` ttk_theme, and the `crystal_remix` iconset +# ----------------------------- +custom = { + "ttk_theme": "xpnative", +} +custom = custom | ss.tp_crystal_remix +ss.themepack(custom) + +# SQL Statement +# ====================================================================================== +# While this example uses triggers to calculate prices and sub/totals, they are not +# required for pysimplesql to operate. See simpler examples, like journal. + +sql = """ +CREATE TABLE IF NOT EXISTS Customers ( + "CustomerID" INTEGER NOT NULL, + "Name" TEXT NOT NULL, + "Email" TEXT, + PRIMARY KEY("CustomerID" AUTOINCREMENT) +); + +CREATE TABLE IF NOT EXISTS Orders ( + "OrderID" INTEGER NOT NULL, + "CustomerID" INTEGER NOT NULL, + "OrderDate" DATE NOT NULL DEFAULT (date('now')), + "Total" REAL, + "Completed" BOOLEAN NOT NULL, + FOREIGN KEY ("CustomerID") REFERENCES Customers(CustomerID), + PRIMARY KEY("OrderID" AUTOINCREMENT) +); + +CREATE TABLE IF NOT EXISTS Products ( + "ProductID" INTEGER NOT NULL, + "Name" TEXT NOT NULL DEFAULT "New Product", + "Price" REAL NOT NULL, + "Inventory" INTEGER DEFAULT 0, + PRIMARY KEY("ProductID" AUTOINCREMENT) +); + +CREATE TABLE IF NOT EXISTS OrderDetails ( + "OrderDetailID" INTEGER NOT NULL, + "OrderID" INTEGER, + "ProductID" INTEGER NOT NULL, + "Quantity" INTEGER, + "Price" REAL, + "SubTotal" REAL GENERATED ALWAYS AS ("Price" * "Quantity") STORED, + FOREIGN KEY ("OrderID") REFERENCES "Orders"("OrderID") ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY ("ProductID") REFERENCES "Products"("ProductID"), + PRIMARY KEY("OrderDetailID" AUTOINCREMENT) +); + +-- Create a compound index on OrderID and ProductID columns in OrderDetails table +CREATE INDEX idx_orderdetails_orderid_productid ON OrderDetails (OrderID, ProductID); + +-- Trigger to set the price value for a new OrderDetail +CREATE TRIGGER IF NOT EXISTS set_price +AFTER INSERT ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE OrderDetails + SET Price = Products.Price + FROM Products + WHERE Products.ProductID = NEW.ProductID + AND OrderDetails.OrderDetailID = NEW.OrderDetailID; +END; + +-- Trigger to update the price value for an existing OrderDetail +CREATE TRIGGER IF NOT EXISTS set_price_update +AFTER UPDATE ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE OrderDetails + SET Price = Products.Price + FROM Products + WHERE Products.ProductID = NEW.ProductID + AND OrderDetails.OrderDetailID = NEW.OrderDetailID; +END; + +-- Trigger to set the total value for a new OrderDetail +CREATE TRIGGER IF NOT EXISTS set_total +AFTER INSERT ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE Orders + SET Total = ( + SELECT SUM(SubTotal) FROM OrderDetails WHERE OrderID = NEW.OrderID + ) + WHERE OrderID = NEW.OrderID; +END; + +-- Trigger to update the total value for an existing OrderDetail +CREATE TRIGGER IF NOT EXISTS update_total +AFTER UPDATE ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE Orders + SET Total = ( + SELECT SUM(SubTotal) FROM OrderDetails WHERE OrderID = NEW.OrderID + ) + WHERE OrderID = NEW.OrderID; +END; + +-- Trigger to update the total value for an existing OrderDetail +CREATE TRIGGER IF NOT EXISTS delete_order_detail +AFTER DELETE ON OrderDetails +FOR EACH ROW +BEGIN + UPDATE Orders + SET Total = ( + SELECT SUM(SubTotal) FROM OrderDetails WHERE OrderID = OLD.OrderID + ) + WHERE OrderID = OLD.OrderID; +END; + +CREATE TRIGGER IF NOT EXISTS update_product_price +AFTER UPDATE ON Products +FOR EACH ROW +BEGIN + UPDATE OrderDetails + SET Price = NEW.Price + WHERE ProductID = NEW.ProductID; +END; + +INSERT INTO Customers (Name, Email) VALUES + ('Alice Rodriguez', 'alice.rodriguez@example.com'), + ('Bryan Patel', 'bryan.patel@example.com'), + ('Cassandra Kim', 'cassandra.kim@example.com'), + ('David Nguyen', 'david.nguyen@example.com'), + ('Ella Singh', 'ella.singh@example.com'), + ('Franklin Gomez', 'franklin.gomez@example.com'), + ('Gabriela Ortiz', 'gabriela.ortiz@example.com'), + ('Henry Chen', 'henry.chen@example.com'), + ('Isabella Kumar', 'isabella.kumar@example.com'), + ('Jonathan Lee', 'jonathan.lee@example.com'), + ('Katherine Wright', 'katherine.wright@example.com'), + ('Liam Davis', 'liam.davis@example.com'), + ('Mia Ali', 'mia.ali@example.com'), + ('Nathan Kim', 'nathan.kim@example.com'), + ('Oliver Brown', 'oliver.brown@example.com'), + ('Penelope Martinez', 'penelope.martinez@example.com'), + ('Quentin Carter', 'quentin.carter@example.com'), + ('Rosa Hernandez', 'rosa.hernandez@example.com'), + ('Samantha Jones', 'samantha.jones@example.com'), + ('Thomas Smith', 'thomas.smith@example.com'), + ('Uma Garcia', 'uma.garcia@example.com'), + ('Valentina Lopez', 'valentina.lopez@example.com'), + ('William Park', 'william.park@example.com'), + ('Xander Williams', 'xander.williams@example.com'), + ('Yara Hassan', 'yara.hassan@example.com'), + ('Zoe Perez', 'zoe.perez@example.com'); + +INSERT INTO Products (Name, Price, Inventory) VALUES + ('Thingamabob', 5.00, 200), + ('Doohickey', 15.00, 75), + ('Whatchamacallit', 25.00, 50), + ('Gizmo', 10.00, 100), + ('Widget', 20.00, 60), + ('Doodad', 30.00, 40), + ('Sprocket', 7.50, 150), + ('Flibbertigibbet', 12.50, 90), + ('Thingamajig', 22.50, 30), + ('Dooberry', 17.50, 50), + ('Whirligig', 27.50, 25), + ('Gadget', 8.00, 120), + ('Contraption', 18.00, 65), + ('Thingummy', 28.00, 35), + ('Dinglehopper', 9.50, 100), + ('Doodlywhatsit', 19.50, 55), + ('Whatnot', 29.50, 20), + ('Squiggly', 6.50, 175), + ('Fluffernutter', 11.50, 80), + ('Goober', 21.50, 40), + ('Doozie', 16.50, 60), + ('Whammy', 26.50, 30), + ('Thingy', 7.00, 130), + ('Doodadery', 17.00, 70); + +INSERT INTO Orders (CustomerID, OrderDate, Completed) +SELECT CustomerID, DATE('now', '-' || (ABS(RANDOM()) % 30) || ' days'), False +FROM Customers +ORDER BY RANDOM() LIMIT 100; + +INSERT INTO OrderDetails (OrderID, ProductID, Quantity) +SELECT O.OrderID, P.ProductID, (ABS(RANDOM()) % 10) + 1 +FROM Orders O +JOIN (SELECT ProductID FROM Products ORDER BY RANDOM() LIMIT 25) P +ON 1=1 +ORDER BY 1; +""" + +# ------------------------- +# CREATE PYSIMPLEGUI LAYOUT +# ------------------------- + +# fmt: off +# Create a basic menu +menu_def = [ + ["&File",["&Save","&Requery All",],], + ["&Edit", ["&Edit Products", "&Edit Customers"]], +] +# fmt: on +layout = [[sg.Menu(menu_def, key="-MENUBAR-", font="_ 12")]] + +# Define the columns for the table selector using the TableHeading class. +order_heading = ss.TableHeadings( + # Click a heading to sort + sort_enable=True, + # Double-click a cell to make edits. + # Exempted: Primary Key columns, Generated columns, and columns set as readonly + edit_enable=True, + # Click 💾 in sg.Table Heading to trigger DataSet.save_record() + save_enable=True, +) + +# Add columns +order_heading.add_column(column="OrderID", heading_column="ID", width=5) +order_heading.add_column("CustomerID", "Customer", 30) +order_heading.add_column("OrderDate", "Date", 20) +order_heading.add_column( + "Total", "Total", width=10, readonly=True +) # set to True to disable editing for individual columns!) +order_heading.add_column("Completed", "✔", 8) + +# Layout +layout.append( + [ + [sg.Text("Orders", font="_16")], + [ + ss.selector( + "Orders", + sg.Table, + num_rows=5, + headings=order_heading, + row_height=25, + ) + ], + [ss.actions("Orders")], + [sg.Sizer(h_pixels=0, v_pixels=20)], + ] +) + +# OrderDetails TableHeadings: +details_heading = ss.TableHeadings(sort_enable=True, edit_enable=True, save_enable=True) +details_heading.add_column("ProductID", "Product", 30) +details_heading.add_column("Quantity", "Quantity", 10) +details_heading.add_column("Price", "Price/Ea", 10, readonly=True) +details_heading.add_column("SubTotal", "SubTotal", 10) + +orderdetails_layout = [ + [sg.Sizer(h_pixels=0, v_pixels=10)], + [ss.field("Orders.CustomerID", sg.Combo, label="Customer")], + [ + ss.field("Orders.OrderDate", label="Date"), + ], + [ss.field("Orders.Completed", sg.Checkbox, default=False)], + [ + ss.selector( + "OrderDetails", + sg.Table, + num_rows=10, + headings=details_heading, + row_height=25, + ) + ], + [ss.actions("OrderDetails", default=False, save=True, insert=True, delete=True)], + [ss.field("OrderDetails.ProductID", sg.Combo)], + [ss.field("OrderDetails.Quantity")], + [ss.field("OrderDetails.Price", sg.Text)], + [ss.field("OrderDetails.SubTotal", sg.Text)], + [sg.Sizer(h_pixels=0, v_pixels=10)], +] + +layout.append([sg.Frame("Order Details", orderdetails_layout, expand_x=True)]) + +win = sg.Window( + "Order Example", + layout, + finalize=True, + # Below is Important! pysimplesql progressbars/popups/quick_editors use + # ttk_theme and icon as defined in themepack. + ttk_theme="xpnative", + icon=ss.themepack.icon, +) + +# Expand our sg.Tables so they fill the screen +win["Orders:selector"].expand(True, True) +win["Orders:selector"].table_frame.pack(expand=True, fill="both") +win["OrderDetails:selector"].expand(True, True) +win["OrderDetails:selector"].table_frame.pack(expand=True, fill="both") + +# Init pysimplesql Driver and Form +# -------------------------------- + +# Create sqlite driver, keeping the database in memory +driver = ss.Driver.sqlite(":memory:", sql_commands=sql) +frm = ss.Form( + driver, + bind_window=win, + live_update=True, # this updates the `Selector`, sg.Table as we type in fields. +) + +# Few more settings +# ----------------- + +frm.edit_protect() # Comment this out to edit protect when the window is created. +# Reverse the default sort order so Orders are sorted by date +frm["Orders"].set_order_clause("ORDER BY OrderDate ASC") +# Requery the data since we made changes to the sort order +frm["Orders"].requery() +# Set the column order for search operations. +frm["Orders"].set_search_order(["CustomerID"]) + +# --------- +# MAIN LOOP +# --------- +while True: + event, values = win.read() + if event == sg.WIN_CLOSED or event == "Exit": + frm.close() # <= ensures proper closing of the sqlite database and runs a database optimization + win.close() + break + # <=== let PySimpleSQL process its own events! Simple! + elif ss.process_events(event, values): + logger.info(f"PySimpleDB event handler handled the event {event}!") + + # Code to automatically save and refresh OrderDetails: + # ---------------------------------------------------- + elif ( + "current_row_updated" in event + and values["current_row_updated"]["data_key"] == "OrderDetails" + ): + dataset = frm["OrderDetails"] + current_row = dataset.get_current_row() + # after a product and quantity is entered, save and requery + if dataset.row_count and current_row["ProductID"] and current_row["Quantity"]: + pk_is_virtual = dataset.pk_is_virtual() + dataset.save_record(display_message=False) + frm["Orders"].requery(select_first=False) + frm.update_selectors("Orders") + # will need to requery if updating, rather than inserting a new record + if not pk_is_virtual: + pk = current_row[dataset.pk_column] + dataset.requery(select_first=False) + dataset.set_by_pk(pk, skip_prompt_save=True) + # ---------------------------------------------------- + + # Display the quick_editor for products and customers + elif "Edit Products" in event: + frm["Products"].quick_editor() + elif "Edit Customers" in event: + frm["Customers"].quick_editor() + # call a Form-level save + elif "Save" in event: + frm.save_records() + # call a Form-level requery + elif "Requery All" in event: + frm.requery_all() + else: + logger.info(f"This event ({event}) is not yet handled.") diff --git a/pysimplesql/pysimplesql.py b/pysimplesql/pysimplesql.py index f6120eca..46852ea0 100644 --- a/pysimplesql/pysimplesql.py +++ b/pysimplesql/pysimplesql.py @@ -54,22 +54,26 @@ from __future__ import annotations # docstrings +import abc import asyncio +import calendar import contextlib +import datetime as dt import enum import functools +import itertools import logging import math import os.path import queue -import threading # threaded popup +import threading import tkinter as tk import tkinter.font as tkfont -from datetime import date, datetime -from time import sleep, time # threaded popup +from time import sleep, time from tkinter import ttk from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypedDict, Union +import numpy as np import pandas as pd import PySimpleGUI as sg @@ -178,9 +182,10 @@ # ---------------------------- # DELETE RETURNS BITMASKS # ---------------------------- -DELETE_FAILED: int = 1 # No result was found -DELETE_RETURNED: int = 2 # A result was found -DELETE_ABORTED: int = 4 # The search was aborted, likely during a callback +# TODO Which ones of these are we actually using? +DELETE_FAILED: int = 1 # Delete failed +DELETE_RETURNED: int = 2 # Delete returned +DELETE_ABORTED: int = 4 # The delete was aborted, likely during a callback DELETE_RECURSION_LIMIT_ERROR: int = 8 # We hit max nested levels # Mysql sets this as 15 when using foreign key CASCADE DELETE @@ -197,8 +202,16 @@ # TK/TTK Widget Types # --------------------- TK_ENTRY = "Entry" +TK_TEXT = "Text" TK_COMBOBOX = "Combobox" TK_CHECKBUTTON = "Checkbutton" +TK_DATEPICKER = "Datepicker" +TK_COMBOBOX_SELECTED = "35" + +# -------------- +# Misc Constants +# -------------- +PK_PLACEHOLDER = "Null" class Boolean(enum.Flag): @@ -263,6 +276,11 @@ def get_val(self): # Return the value portion of the row return self.val + def get_pk_ignore_placeholder(self): + if self.pk == PK_PLACEHOLDER: + return None + return self.pk + def get_instance(self): # Return this instance of the row return self @@ -351,7 +369,7 @@ def parent_virtual(cls, table: str, frm: Form) -> Union[bool, None]: for r in cls.instances: if r.child_table == table and r.on_update_cascade: try: - return frm[r.parent_table].row_is_virtual() + return frm[r.parent_table].pk_is_virtual() except AttributeError: return False return None @@ -886,7 +904,7 @@ def records_changed(self, column: str = None, recursive=True) -> bool: logger.debug(f'Checking if records have changed in table "{self.table}"...') # Virtual rows wills always be considered dirty - if self.row_is_virtual(): + if self.pk_is_virtual(): return True if self.current_row_has_backup and not self.get_current_row().equals( @@ -903,6 +921,10 @@ def records_changed(self, column: str = None, recursive=True) -> bool: if column is not None and mapped.column != column: continue + # if sg.Text + if isinstance(mapped.element, sg.Text): + continue + # don't check if there aren't any rows. Fixes checkbox = '' when no # rows. if not len(self.frm[mapped.table].rows.index): @@ -926,7 +948,7 @@ def records_changed(self, column: str = None, recursive=True) -> bool: mapped.column, table_val, element_val, - bool(type(mapped.element) is sg.PySimpleGUI.Checkbox), + bool(isinstance(mapped.element, sg.Checkbox)), ) if new_value is not Boolean.FALSE: dirty = True @@ -953,7 +975,7 @@ def records_changed(self, column: str = None, recursive=True) -> bool: # TODO: How to type-hint this return? def value_changed( self, column_name: str, old_value, new_value, is_checkbox: bool - ) -> Union[Any, bool]: + ) -> Union[Any, Boolean]: """ Verifies if a new value is different from an old value and returns the cast value ready to be inserted into a database. @@ -976,6 +998,7 @@ def value_changed( if col["name"] == column_name: new_value = col.cast(new_value) element_val = new_value + table_val = col.cast(table_val) break if is_checkbox: @@ -988,9 +1011,9 @@ def value_changed( table_val = "" # Strip trailing whitespace from strings - if type(table_val) is str: + if isinstance(table_val, str): table_val = table_val.rstrip() - if type(element_val) is str: + if isinstance(element_val, str): element_val = element_val.rstrip() # Make the comparison @@ -1018,7 +1041,6 @@ def prompt_save( `PROMPT_DISCARDED`, or `PROMPT_NONE`. """ # Return False if there is nothing to check or _prompt_save is False - # TODO: children too? if self.current_index is None or not self.row_count or not self._prompt_save: return PROMPT_SAVE_NONE @@ -1339,6 +1361,7 @@ def search( update_elements: bool = True, requery_dependents: bool = True, skip_prompt_save: bool = False, + display_message: bool = None, ) -> Union[SEARCH_FAILED, SEARCH_RETURNED, SEARCH_ABORTED]: """ Move to the next record in the `DataSet` that contains `search_string`. @@ -1357,6 +1380,8 @@ def search( records. :param requery_dependents: (optional) Requery dependents after switching records :param skip_prompt_save: (optional) True to skip prompting to save dirty records + :param display_message: Displays a message "Search Failed: ...", otherwise is + silent on fail. :returns: One of the following search values: `SEARCH_FAILED`, `SEARCH_RETURNED`, `SEARCH_ABORTED`. """ @@ -1386,45 +1411,82 @@ def search( ): return None - # First lets make a search order.. TODO: remove this hard coded garbage if self.row_count: logger.debug(f"DEBUG: {self.search_order} {self.rows.columns[0]}") - for field in self.search_order: - # Perform a search for str, from the current position to the end and back by - # creating a list of all indexes - for i in list(range(self.current_index + 1, self.row_count)) + list( - range(0, self.current_index) - ): - if ( - field in list(self.rows.columns) - and search_string.lower() in str(self.rows.iloc[i][field]).lower() - ): - old_index = self.current_index - if i == old_index: - return None - self.current_index = i + + # reorder rows to be idx + 1, and wrap around back to the beginning + rows = self.rows.copy().reset_index() + idx = self.current_index + 1 % len(rows) + rows = pd.concat([rows.loc[idx:], rows.loc[:idx]]) + + # fill in descriptions for cols in search_order + rels = Relationship.get_relationships(self.table) + for col in self.search_order: + for rel in rels: + if col == rel.fk_column: + parent_df = self.frm[rel.parent_table].rows + parent_pk_column = self.frm[rel.parent_table].pk_column + + # get this before map(), to revert below + parent_current_row = self.frm[ + rel.parent_table + ].get_original_current_row() + condition = rows[col] == parent_current_row[parent_pk_column] + + description_column = self.frm[rel.parent_table].description_column + mapping_dict = parent_df.set_index(parent_pk_column)[ + description_column + ].to_dict() + rows[col] = rows[col].map(mapping_dict) + + # revert any unsaved changes + rows.loc[condition, col] = parent_current_row[description_column] + continue + + for column in self.search_order: + # search through processed rows, looking for search_string + result = rows[ + rows[column].astype(str).str.contains(str(search_string), case=False) + ] + if not result.empty: + old_index = self.current_index + # grab the first result + pk = result.iloc[0][self.pk_column] + if pk == self[self.pk_column]: if update_elements: self.frm.update_elements(self.key) if requery_dependents: self.requery_dependents() - - # callback - if "after_search" in self.callbacks and not self.callbacks[ - "after_search" - ](self.frm, self.frm.window): - self.current_index = old_index - self.frm.update_elements(self.key) - self.requery_dependents() - return SEARCH_ABORTED - - # callback - if "record_changed" in self.callbacks: - self.callbacks["record_changed"](self.frm, self.frm.window) - return SEARCH_RETURNED + self.set_by_pk( + pk=pk, + update_elements=update_elements, + requery_dependents=requery_dependents, + skip_prompt_save=True, + ) + + # callback + if "after_search" in self.callbacks and not self.callbacks[ + "after_search" + ](self.frm, self.frm.window): + self.current_index = old_index + self.frm.update_elements(self.key) + self.requery_dependents() + return SEARCH_ABORTED + + # callback + if "record_changed" in self.callbacks: + self.callbacks["record_changed"](self.frm, self.frm.window) + + return SEARCH_RETURNED + self.frm.popup.ok( + lang.dataset_search_failed_title, + lang.dataset_search_failed.format_map( + LangFormat(search_string=search_string) + ), + ) return SEARCH_FAILED # If we have made it here, then it was not found! - # sg.Popup('Search term "'+str+'" not found!') # TODO: Play sound? def set_by_index( @@ -1506,7 +1568,14 @@ def set_by_pk( # Get the numerical index of where the primary key is located. # If the pk value can't be found, set to the last index - idx = [i for i, value in enumerate(self.rows[self.pk_column]) if value == pk] + try: + idx = [ + i for i, value in enumerate(self.rows[self.pk_column]) if value == pk + ] + except IndexError: + idx = None + logger.debug("Error finding pk!") + idx = idx[0] if idx else self.row_count self.set_by_index( @@ -1532,12 +1601,14 @@ def get_current( """ logger.debug(f"Getting current record for {self.table}.{column}") if self.row_count: - if self.get_current_row()[column]: + if self.get_current_row()[column] is not None: return self.get_current_row()[column] return default return default - def set_current(self, column: str, value: Union[str, int]) -> None: + def set_current( + self, column: str, value: Union[str, int], write_event: bool = False + ) -> None: """ Set the value for the supplied column in the current row, making a backup if needed. @@ -1547,11 +1618,29 @@ def set_current(self, column: str, value: Union[str, int]) -> None: :param column: The column you want to set the value for :param value: A value to set the current record's column to + :param write_event: (optional) If True, writes an event to PySimpleGui + as `current_row_updated`. :returns: None """ logger.debug(f"Setting current record for {self.key}.{column} = {value}") self.backup_current_row() self.rows.loc[self.rows.index[self.current_index], column] = value + if write_event: + self.frm.window.write_event_value( + "current_row_updated", + { + "frm_reference": self.frm, + "data_key": self.key, + "column": column, + "value": value, + }, + ) + + # # TODO: I'd like to talk about extending callbacks to include + # # data_key (if callback is for a specific data_key) + # if "current_row_updated" in dataset.callbacks: + # dataset.callbacks["current_row_updated"]( + # self.frm, self.frm.window, self.key) def get_keyed_value( self, value_column: str, key_column: str, key_value: Union[str, int] @@ -1590,7 +1679,8 @@ def get_current_row(self) -> Union[pd.Series, None]: # For child reparenting self.current_index = self.current_index - return self.rows.iloc[self.current_index] + # make sure to return as python type + return self.rows.astype("O").iloc[self.current_index] return None def add_selector( @@ -1613,12 +1703,7 @@ def add_selector( :param where_value: (optional) :returns: None """ - if type(element) not in [ - sg.PySimpleGUI.Listbox, - sg.PySimpleGUI.Slider, - sg.Combo, - sg.Table, - ]: + if not isinstance(element, (sg.Listbox, sg.Slider, sg.Combo, sg.Table)): raise RuntimeError( f"add_selector() error: {element} is not a supported element." ) @@ -1647,10 +1732,6 @@ def insert_record( the insert. :returns: None """ - # todo: you don't add a record if there isn't a parent!!! - # todo: this is currently filtered out by enabling of the element, but it should - # be filtered here too! - # todo: bring back the values parameter? # prompt_save if ( not skip_prompt_save @@ -1742,7 +1823,7 @@ def save_record( return SAVE_NONE + SHOW_MESSAGE # Work with a copy of the original row and transform it if needed - # Note that while saving, we are working with just the current row of data, + # While saving, we are working with just the current row of data, # unless it's 'keyed' via ?/= current_row = self.get_current_row().copy() @@ -1753,8 +1834,24 @@ def save_record( # Propagate GUI data back to the stored current_row for mapped in [m for m in self.frm.element_map if m.dataset == self]: + # skip if sg.Text + if isinstance(mapped.element, sg.Text): + continue + # convert the data into the correct type using the domain in ColumnInfo - element_val = self.column_info[mapped.column].cast(mapped.element.get()) + if isinstance(mapped.element, sg.Combo): + # try to get ElementRow pk + try: + element_val = self.column_info[mapped.column].cast( + mapped.element.get().get_pk_ignore_placeholder() + ) + # of if plain-ole combobox: + except AttributeError: + element_val = self.column_info[mapped.column].cast( + mapped.element.get() + ) + else: + element_val = self.column_info[mapped.column].cast(mapped.element.get()) # Looked for keyed elements first if mapped.where_column is not None: @@ -1789,11 +1886,12 @@ def save_record( current_row[mapped.column] = element_val # create diff of columns if not virtual - new_dict = dict(current_row.items()) - if self.row_is_virtual(): + new_dict = current_row.fillna("").to_dict() + + if self.pk_is_virtual(): changed_row_dict = new_dict else: - old_dict = dict(self.get_original_current_row().items()) + old_dict = self.get_original_current_row().fillna("").to_dict() changed_row_dict = { key: new_dict[key] for key in new_dict @@ -1826,6 +1924,13 @@ def save_record( if self.transform is not None: self.transform(self, changed_row_dict, TFORM_ENCODE) + # delete generated rows + changed_row_dict = { + col: value + for col, value in changed_row_dict.items() + if self.column_info[col] and not self.column_info[col]["generated"] + } + # Save or Insert the record as needed if keyed_queries is not None: # Now execute all the saved queries from earlier @@ -1847,7 +1952,7 @@ def save_record( return SAVE_FAIL # Do not show the message in this case else: - if self.row_is_virtual(): + if self.pk_is_virtual(): result = self.driver.insert_record( self.table, self.get_current_pk(), self.pk_column, changed_row_dict ) @@ -1872,20 +1977,20 @@ def save_record( if result.attrs["lastrowid"] is not None else self.get_current_pk() ) - current_row[self.pk_column] = pk + self.set_current(self.pk_column, pk, write_event=False) # then update the current row data self.rows.iloc[self.current_index] = current_row # If child changes parent, move index back and requery/requery_dependents if ( - cascade_fk_changed and not self.row_is_virtual() + cascade_fk_changed and not self.pk_is_virtual() ): # Virtual rows already requery, and have no dependents. self.frm[self.table].requery(select_first=False) # keep spot in table self.frm[self.table].requery_dependents() # Lets refresh our data - if self.row_is_virtual(): + if self.pk_is_virtual(): # Requery so that the new row honors the order clause self.requery(select_first=False, update_elements=False) if update_elements: @@ -2009,7 +2114,7 @@ def delete_record( if answer == "no": return True - if self.row_is_virtual(): + if self.pk_is_virtual(): self.purge_virtual() self.frm.update_elements(self.key) # only need to reset the Insert button @@ -2063,7 +2168,7 @@ def duplicate_record( :returns: None """ # Ensure that there is actually something to duplicate - if not self.row_count or self.row_is_virtual(): + if not self.row_count or self.pk_is_virtual(): return None # callback @@ -2117,6 +2222,7 @@ def duplicate_record( keep_on_top=True, modal=True, ttk_theme=themepack.ttk_theme, + icon=themepack.icon, ).read(close=True) if answer[0] == "parent": children = False @@ -2183,28 +2289,20 @@ def get_description_for_pk(self, pk: int) -> Union[str, int, None]: def virtual_pks(self): return self.rows.attrs["virtual"] - def row_is_virtual(self, index: int = None) -> bool: + def pk_is_virtual(self, pk: int = None) -> bool: """ - Check whether the row at `index` is virtual + Check whether pk is virtual - :param index: The index to check. If none is passed, then the current index will - be used. + :param pk: The pk to check. If None, the pk of the current row will be checked. :returns: True or False based on whether the row is virtual """ if not self.row_count: return False - if index is None: - index = self.current_index - - try: - pk = self.rows.loc[self.rows.index[index]][self.pk_column] - except IndexError: - return False + if pk is None: + pk = self.get_current_row()[self.pk_column] - if self.rows is not None and self.row_count: - return bool(pk in self.virtual_pks) - return False + return bool(pk in self.virtual_pks) @property def row_count(self) -> int: @@ -2230,7 +2328,7 @@ def current_row_has_backup(self) -> bool: :returns: True if a backup row is present that matches, and False otherwise. """ - if self.rows is None: + if self.rows is None or self.rows.empty: return False if ( isinstance(self.rows.attrs["row_backup"], pd.Series) @@ -2300,45 +2398,85 @@ def table_values( columns = all_columns if columns is None else columns + rows = self.rows.copy() pk_column = self.pk_column - virtual_row_pks = self.virtual_pks - unsaved_pk_idx = None - if self.current_row_has_backup and not self.get_current_row().equals( - self.get_original_current_row() - ): - unsaved_pk_idx = self.rows.loc[ - self.rows[pk_column] == self.get_current_row()[pk_column] - ].index[0] + if mark_unsaved: + virtual_row_pks = self.virtual_pks.copy() + # add pk of current row if it has changes + if self.current_row_has_backup and not self.get_current_row().equals( + self.get_original_current_row() + ): + virtual_row_pks.append( + self.rows.loc[ + self.rows[pk_column] == self.get_current_row()[pk_column], + pk_column, + ].values[0] + ) + # Create a new column 'marker' with the desired values + rows["marker"] = " " + mask = rows[pk_column].isin(virtual_row_pks) + rows.loc[mask, "marker"] = themepack.marker_unsaved + else: + rows["marker"] = " " + + # get fk descriptions rels = Relationship.get_relationships(self.table) + for col in columns: + for rel in rels: + if col == rel.fk_column: + parent_df = self.frm[rel.parent_table].rows + parent_pk_column = self.frm[rel.parent_table].pk_column + + # get this before map(), to revert below + parent_current_row = self.frm[ + rel.parent_table + ].get_original_current_row() + condition = rows[col] == parent_current_row[parent_pk_column] + + # map descriptions to fk column + description_column = self.frm[rel.parent_table].description_column + mapping_dict = parent_df.set_index(parent_pk_column)[ + description_column + ].to_dict() + rows[col] = rows[col].map(mapping_dict) + + # revert any unsaved changes for the single row + rows.loc[condition, col] = parent_current_row[description_column] + continue - def process_row(row): - lst = [] - pk = row[pk_column] - if mark_unsaved and (pk in virtual_row_pks or unsaved_pk_idx == row.name): - lst.append(themepack.marker_unsaved) - else: - lst.append(" ") - - # only loop through passed-in columns - for col in columns: - is_fk_column = any(rel.fk_column == col for rel in rels) - if is_fk_column: - for rel in rels: - if col == rel.fk_column: - lst.append( - self.frm[rel.parent_table].get_description_for_pk( - row[col] - ) - ) - break - else: - lst.append(row[col]) + # transform bool + if themepack.display_boolean_as_checkbox: + bool_columns = [ + column + for column in columns + if self.column_info[column] + and self.column_info[column]["domain"] in ["BOOLEAN"] + ] + for col in bool_columns: + rows[col] = np.where( + rows[col], themepack.checkbox_true, themepack.checkbox_false + ) - return TableRow(pk, lst) + # set the pk to the index to use below + rows["pk_idx"] = rows[pk_column].copy() + rows.set_index("pk_idx", inplace=True) - return self.rows.apply(process_row, axis=1) + # insert the marker + columns.insert(0, "marker") + + # resort rows with requested columns + rows = rows[columns] + + # fastest way yet to generate list of TableRows + return [ + TableRow(pk, values.tolist()) + for pk, values in zip( + rows.index, + np.vstack((rows.fillna("").astype("O").values.T, rows.index)).T, + ) + ] def column_likely_in_selector(self, column: str) -> bool: """ @@ -2364,7 +2502,9 @@ def column_likely_in_selector(self, column: str) -> bool: for e in self.selector ) - def combobox_values(self, column_name) -> List[ElementRow] or None: + def combobox_values( + self, column_name, insert_placeholder: bool = True + ) -> List[ElementRow] or None: """ Returns the values to use in a sg.Combobox as a list of ElementRow objects. @@ -2380,20 +2520,23 @@ def combobox_values(self, column_name) -> List[ElementRow] or None: if rel is None: return None - target_table = self.frm[rel.parent_table] - pk_column = target_table.pk_column - description = target_table.description_column + rows = self.frm[rel.parent_table].rows.copy() + pk_column = self.frm[rel.parent_table].pk_column + description = self.frm[rel.parent_table].description_column - backup = None - if target_table.current_row_has_backup: - backup = target_table.get_original_current_row() + # revert to original row (so unsaved changes don't show up in dropdowns) + parent_current_row = self.frm[rel.parent_table].get_original_current_row() + rows.iloc[self.frm[rel.parent_table].current_index] = parent_current_row - def process_row(row): - if backup is not None and backup[pk_column] == row[pk_column]: - return ElementRow(backup[pk_column].tolist(), backup[description]) - return ElementRow(row[pk_column], row[description]) + # fastest way yet to generate this list of ElementRow + combobox_values = [ + ElementRow(*values) + for values in np.column_stack((rows[pk_column], rows[description])) + ] - return target_table.rows.apply(process_row, axis=1).tolist() + if insert_placeholder: + combobox_values.insert(0, ElementRow("Null", lang.combo_placeholder)) + return combobox_values def get_related_table_for_column(self, column: str) -> str: """ @@ -2440,12 +2583,15 @@ def quick_editor( keygen.reset() data_key = self.key layout = [] - headings = self.column_info.names() - visible = [1] * len(headings) - visible[0] = 0 - col_width = int(55 / (len(headings) - 1)) - for i in range(0, len(headings)): - headings[i] = headings[i].ljust(col_width, " ") + headings = TableHeadings(sort_enable=True, edit_enable=True, save_enable=True) + + for col in self.column_info.names(): + # set widths + width = int(55 / (len(self.column_info.names()) - 1)) + if col == self.pk_column: + # make pk column either max length of contained pks, or len of name + width = max(self.rows[col].astype(str).map(len).max(), len(col) + 1) + headings.add_column(col, col.capitalize(), width=width) layout.append( [ @@ -2454,18 +2600,43 @@ def quick_editor( sg.Table, key=f"{data_key}:quick_editor", num_rows=10, + row_height=25, headings=headings, - visible_column_map=visible, ) ] ) + y_pad = 10 layout.append([actions(data_key, edit_protect=False)]) - layout.append([sg.Text("")]) - layout.append([sg.HorizontalSeparator()]) + layout.append([sg.Sizer(h_pixels=0, v_pixels=y_pad)]) + + fields_layout = [[sg.Sizer(h_pixels=0, v_pixels=y_pad)]] + + rels = Relationship.get_relationships(self.table) for col in self.column_info.names(): + found = False column = f"{data_key}.{col}" + # make sure isn't pk if col != self.pk_column: - layout.append([field(column)]) + # display checkboxes + if self.column_info[col]["domain"] in ["BOOLEAN"]: + fields_layout.append([field(column, sg.Checkbox)]) + found = True + break + # or display sg.combos + for rel in rels: + if col == rel.fk_column: + fields_layout.append( + [field(column, sg.Combo, quick_editor=False)] + ) + found = True + break + # otherwise, just display a regular input + if not found: + fields_layout.append([field(column)]) + + fields_layout.append([sg.Sizer(h_pixels=0, v_pixels=y_pad)]) + layout.append([sg.Frame("Fields", fields_layout, expand_x=True)]) + layout.append([sg.Sizer(h_pixels=0, v_pixels=10)]) quick_win = sg.Window( lang.quick_edit_title.format_map(LangFormat(data_key=data_key)), @@ -2474,8 +2645,9 @@ def quick_editor( modal=True, finalize=True, ttk_theme=themepack.ttk_theme, # Must, otherwise will redraw window + icon=themepack.icon, ) - quick_frm = Form(self.frm.driver, bind_window=quick_win) + quick_frm = Form(self.frm.driver, bind_window=quick_win, live_update=True) # Select the current entry to start with if pk_update_funct is not None: @@ -2495,6 +2667,8 @@ def quick_editor( break logger.debug(f"This event ({event}) is not yet handled.") + if quick_frm.popup.popup_info: + quick_frm.popup.popup_info.close() quick_win.close() self.requery() self.frm.update_elements() @@ -2696,7 +2870,9 @@ def insert_row(self, row: dict, idx: int = None) -> None: :param idx: The index where the row should be inserted (default to last index) :returns: None """ - row_series = pd.Series(row) + row_series = pd.Series(row, dtype=object) + # Infer better data types for the Series + # row_series = row_series.infer_objects() if self.rows.empty: self.rows = Result.set( pd.concat([self.rows, row_series.to_frame().T], ignore_index=True) @@ -2856,6 +3032,8 @@ def close(self, reset_keygen: bool = True): """ # First delete the dataset associated DataSet.purge_form(self, reset_keygen) + if self.popup.popup_info: + self.popup.popup_info.close() self.driver.close() def bind(self, win: sg.Window) -> None: @@ -2878,7 +3056,7 @@ def bind(self, win: sg.Window) -> None: self.update_elements() # Creating cell edit instance, even if we arn't going to use it. self._celledit = _CellEdit(self) - self.window.TKroot.bind("", self._celledit) + self.window.TKroot.bind("", self._celledit, "+") self._liveupdate = _LiveUpdate(self) if self.live_update: self.set_live_update(enable=True) @@ -3185,7 +3363,7 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: element = win[key] # Skip this element if there is no metadata present - if type(element.metadata) is not dict: + if not isinstance(element.metadata, dict): continue # Process the filter to ensure this element should be mapped to this Form @@ -3237,6 +3415,14 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: self.map_element( element, self[table], col, where_column, where_value ) + if isinstance(element, (_EnhancedInput, _EnhancedMultiline)) and ( + col in self[table].column_info.names() + and self[table].column_info[col].notnull + ): + element.add_placeholder( + placeholder=lang.notnull_placeholder, + color=themepack.placeholder_color, + ) # Map Selector Element elif element.metadata["type"] == TYPE_SELECTOR: @@ -3259,7 +3445,10 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: ) # Enable sorting if TableHeading is present - if type(element) is sg.Table and "TableHeading" in element.metadata: + if ( + isinstance(element, sg.Table) + and "TableHeading" in element.metadata + ): table_heading: TableHeadings = element.metadata["TableHeading"] # We need a whole chain of things to happen # when a heading is clicked on: @@ -3267,9 +3456,9 @@ def auto_map_elements(self, win: sg.Window, keys: List[str] = None) -> None: # 2 Run TableHeading.update_headings() with the: # Table element, sort_column, sort_reverse # 3 Run update_elements() to see the changes - table_heading.enable_sorting( + table_heading.enable_heading_function( element, - _SortCallbackWrapper(self, data_key), + _HeadingCallback(self, data_key), ) else: @@ -3352,7 +3541,7 @@ def auto_map_events(self, win: sg.Window) -> None: # key = str(key) # sometimes end up with an integer element 0?TODO:Research element = win[key] # Skip this element if there is no metadata present - if type(element.metadata) is not dict: + if not isinstance(element.metadata, dict): logger.debug(f"Skipping mapping of {key}") continue if element.metadata["Form"] != self: @@ -3401,6 +3590,10 @@ def auto_map_events(self, win: sg.Window) -> None: search_box = f"{search_element}:search_input" if data_key: funct = functools.partial(self[data_key].search, search_box) + self.window[search_box].add_placeholder( + placeholder=lang.search_placeholder, + color=themepack.placeholder_color, + ) # elif event_type==EVENT_SEARCH_DB: elif event_type == EVENT_QUICK_EDIT: referring_table = table @@ -3702,7 +3895,7 @@ def update_actions(self, target_data_key: str = None) -> None: disable = bool( not row_count or self._edit_protect - or self[data_key].row_is_virtual() + or self[data_key].pk_is_virtual() ) win[m["event"]].update(disabled=disable) @@ -3779,35 +3972,36 @@ def update_fields( if mapped.element in omit_elements: continue - if combo_values_only and type(mapped.element) is not sg.PySimpleGUI.Combo: + if combo_values_only and not isinstance(mapped.element, sg.Combo): continue if len(columns) and mapped.column not in columns: continue - if type(mapped.element) is not sg.Text: # don't show markers for sg.Text - # Show the Required Record marker if the column has notnull set and - # this is a virtual row - marker_key = mapped.element.key + ":marker" - try: - if mapped.dataset.row_is_virtual(): - # get the column name from the key - col = mapped.column - # get notnull from the column info - if ( - col in mapped.dataset.column_info.names() - and mapped.dataset.column_info[col].notnull - ): - self.window[marker_key].update( - visible=True, - text_color=themepack.marker_required_color, - ) - else: - self.window[marker_key].update(visible=False) - if self.window is not None: - self.window[marker_key].update(visible=False) - except AttributeError: + # Update Markers + # -------------------------------------------------------------------------- + # Show the Required Record marker if the column has notnull set and + # this is a virtual row + marker_key = mapped.element.key + ":marker" + try: + if mapped.dataset.pk_is_virtual(): + # get the column name from the key + col = mapped.column + # get notnull from the column info + if ( + col in mapped.dataset.column_info.names() + and mapped.dataset.column_info[col].notnull + ): + self.window[marker_key].update( + visible=True, + text_color=themepack.marker_required_color, + ) + else: self.window[marker_key].update(visible=False) + if self.window is not None: + self.window[marker_key].update(visible=False) + except AttributeError: + self.window[marker_key].update(visible=False) updated_val = None # If there is a callback for this element, use it @@ -3820,17 +4014,14 @@ def update_fields( updated_val = mapped.dataset.get_keyed_value( mapped.column, mapped.where_column, mapped.where_value ) - if type(mapped.element) in [ - sg.PySimpleGUI.CBox - ]: # TODO, may need to add more?? + # TODO, may need to add more?? + if isinstance(mapped.element, sg.Checkbox): updated_val = checkbox_to_bool(updated_val) - elif type(mapped.element) is sg.PySimpleGUI.Combo: + elif isinstance(mapped.element, sg.Combo): # Update elements with foreign dataset first # This will basically only be things like comboboxes - # TODO: move this to only compute if something else changes? # Find the relationship to determine which table to get data from - # TODO this should be get_relationships_for_data? combo_vals = mapped.dataset.combobox_values(mapped.column) if not combo_vals: logger.info( @@ -3860,33 +4051,39 @@ def update_fields( # and update element mapped.element.update(values=combo_vals) - elif type(mapped.element) is sg.PySimpleGUI.Table: + elif isinstance(mapped.element, sg.Text): + rels = Relationship.get_relationships(mapped.dataset.table) + found = False + # try to get description of linked if foreign-key + for rel in rels: + if mapped.column == rel.fk_column: + updated_val = mapped.dataset.frm[ + rel.parent_table + ].get_description_for_pk(mapped.dataset[mapped.column]) + found = True + break + if not found: + updated_val = mapped.dataset[mapped.column] + mapped.element.update("") + + elif isinstance(mapped.element, sg.Table): # Tables use an array of arrays for values. Note that the headings # can't be changed. values = mapped.dataset.table_values() # Select the current one pk = mapped.dataset.get_current_pk() - if len(values): + if len(values): # noqa SIM108 # set index to pk index = [[v[0] for v in values].index(pk)] - # calculate pk percentage position - pk_position = index[0] / len(values) else: # if empty index = [] - pk_position = 0 # Update table, and set vertical scroll bar to follow selected element - update_table_element( - self.window, mapped.element, values, index, pk_position - ) + update_table_element(self.window, mapped.element, values, index) continue - elif type(mapped.element) in [ - sg.PySimpleGUI.InputText, - sg.PySimpleGUI.Multiline, - sg.PySimpleGUI.Text, - ]: + elif isinstance(mapped.element, (sg.Input, sg.Multiline)): # Update the element in the GUI # For text objects, lets clear it first... @@ -3895,10 +4092,10 @@ def update_fields( updated_val = mapped.dataset[mapped.column] - elif type(mapped.element) is sg.PySimpleGUI.Checkbox: + elif isinstance(mapped.element, sg.Checkbox): updated_val = checkbox_to_bool(mapped.dataset[mapped.column]) - elif type(mapped.element) is sg.PySimpleGUI.Image: + elif isinstance(mapped.element, sg.Image): val = mapped.dataset[mapped.column] try: @@ -3956,17 +4153,13 @@ def update_selectors( if element.key in self.callbacks: self.callbacks[element.key]() - if ( - type(element) == sg.PySimpleGUI.Listbox - or type(element) == sg.PySimpleGUI.Combo - ): + if isinstance(element, (sg.Listbox, sg.Combo)): logger.debug("update_elements: List/Combo selector found...") lst = [] for _, r in dataset.rows.iterrows(): if e["where_column"] is not None: - if str(r[e["where_column"]]) == str( - e["where_value"] - ): # TODO: Kind of a hackish way to check for equality. + # TODO: Kind of a hackish way to check for equality. + if str(r[e["where_column"]]) == str(e["where_value"]): lst.append( ElementRow(r[pk_column], r[description_column]) ) @@ -3977,11 +4170,14 @@ def update_selectors( ElementRow(r[pk_column], r[description_column]) ) - element.update(values=lst, set_to_index=dataset.current_index) + element.update( + values=lst, + set_to_index=dataset.current_index, + ) # set vertical scroll bar to follow selected element # (for listboxes only) - if type(element) == sg.PySimpleGUI.Listbox: + if isinstance(element, sg.Listbox): try: element.set_vscroll_position( dataset.current_index / len(lst) @@ -3989,12 +4185,12 @@ def update_selectors( except ZeroDivisionError: element.set_vscroll_position(0) - elif type(element) == sg.PySimpleGUI.Slider: + elif isinstance(element, sg.Slider): # Re-range the element depending on the number of records l = dataset.row_count # noqa: E741 element.update(value=dataset._current_index + 1, range=(1, l)) - elif type(element) is sg.PySimpleGUI.Table: + elif isinstance(element, sg.Table): logger.debug("update_elements: Table selector found...") # Populate entries try: @@ -4013,19 +4209,14 @@ def update_selectors( if len(values): # set to index by pk index = [[v.pk for v in values].index(pk)] - # calculate pk percentage position - pk_position = index[0] / len(values) found = True else: # if empty index = [] - pk_position = 0 logger.debug(f"Selector:: index:{index} found:{found}") # Update table, and set vertical scroll bar to follow - update_table_element( - self.window, element, values, index, pk_position - ) + update_table_element(self.window, element, values, index) def requery_all( self, @@ -4097,23 +4288,23 @@ def process_events(self, event: str, values: list) -> bool: element: sg.Element = e["element"] if element.key == event and len(dataset.rows) > 0: changed = False # assume that a change will not take place - if type(element) == sg.PySimpleGUI.Listbox: + if isinstance(element, sg.Listbox): row = values[element.Key][0] dataset.set_by_pk(row.get_pk()) changed = True - elif type(element) == sg.PySimpleGUI.Slider: + elif isinstance(element, sg.Slider): dataset.set_by_index(int(values[event]) - 1) changed = True - elif type(element) == sg.PySimpleGUI.Combo: + elif isinstance(element, sg.Combo): row = values[event] dataset.set_by_pk(row.get_pk()) changed = True - elif type(element) is sg.PySimpleGUI.Table and len( - values[event] - ): - index = values[event][0] - pk = self.window[event].Values[index].pk - + elif isinstance(element, sg.Table) and len(values[event]): + if isinstance(element, LazyTable): + pk = int(values[event]) + else: + index = values[event][0] + pk = self.window[event].Values[index].pk # no need to update the selector! dataset.set_by_pk(pk, True, omit_elements=[element]) @@ -4138,12 +4329,7 @@ def update_element_states( if mapped.table != table: continue element = mapped.element - if type(element) in [ - sg.PySimpleGUI.InputText, - sg.PySimpleGUI.MLine, - sg.PySimpleGUI.Combo, - sg.PySimpleGUI.Checkbox, - ]: + if isinstance(element, (sg.Input, sg.Multiline, sg.Combo, sg.Checkbox)): # if element.Key in self.window.key_dict.keys(): logger.debug( f"Updating element {element.Key} to disabled: " @@ -4244,7 +4430,6 @@ def update_table_element( element: Type[sg.Table], values: List[TableRow], select_rows: List[int], - vscroll_position: float = None, ) -> None: """ Updates a PySimpleGUI sg.Table with new data and suppresses extra events emitted. @@ -4257,19 +4442,19 @@ def update_table_element( :param element: The sg.Table element to be updated. :param values: A list of table rows to update the sg.Table with. :param select_rows: List of rows to select as if user did. - :param vscroll_position: From 0 to 1.0, the percentage from the top to move - scrollbar to. :returns: None """ # Disable handling for "<>" event - element.Widget.unbind("<>") + element.widget.unbind("<>") # update element element.update(values=values, select_rows=select_rows) - # set vertical scroll bar to follow selected element - # call even for 0.0, so that a 'reset sort' repositions vscroll to top. - if vscroll_position is not None: - element.set_vscroll_position(vscroll_position) + + # make sure row_iid is visible + if not isinstance(element, LazyTable) and len(values): + row_iid = element.tree_ids[select_rows[0]] + element.widget.see(row_iid) + window.refresh() # Event handled and bypassed # Enable handling for "<>" event element.widget.bind("<>", element._treeview_selected) @@ -4282,7 +4467,16 @@ def checkbox_to_bool(value): :param value: Value to convert into True or False :returns: bool """ - return str(value).lower() in ["y", "yes", "t", "true", "1"] + return str(value).lower() in [ + "y", + "yes", + "t", + "true", + "1", + "on", + "enabled", + themepack.checkbox_true, + ] class Popup: @@ -4328,6 +4522,7 @@ def ok(self, title, msg): ttk_theme=themepack.ttk_theme, element_justification="center", enable_close_attempted_event=True, + icon=themepack.icon, ) while True: @@ -4369,6 +4564,7 @@ def yes_no(self, title, msg): ttk_theme=themepack.ttk_theme, element_justification="center", enable_close_attempted_event=True, + icon=themepack.icon, ) while True: @@ -4402,6 +4598,8 @@ def info( if display_message: msg_lines = msg.splitlines() layout = [[sg.Text(line, font="bold")] for line in msg_lines] + if self.popup_info: + return self.popup_info = sg.Window( title=title, layout=layout, @@ -4412,6 +4610,7 @@ def info( element_justification="center", ttk_theme=themepack.ttk_theme, enable_close_attempted_event=True, + icon=themepack.icon, ) self.window.TKroot.after(int(auto_close_seconds * 1000), self._auto_close) @@ -4419,7 +4618,9 @@ def _auto_close(self): """ Use in a tk.after to automatically close the popup_info. """ - self.popup_info.close() + if self.popup_info: + self.popup_info.close() + self.popup_info = None class ProgressBar: @@ -4491,6 +4692,7 @@ def _create_window(self): keep_on_top=True, finalize=True, ttk_theme=themepack.ttk_theme, + icon=themepack.icon, ) @@ -4607,6 +4809,7 @@ async def _gui(self): keep_on_top=True, finalize=True, ttk_theme=themepack.ttk_theme, + icon=themepack.icon, ) current_count = 0 @@ -4687,19 +4890,6 @@ def _animated_message(self, phrases: list, phrase_delay: float): return current_message -class LangFormat(dict): - - """ - This is a convenience class used by LanguagePack format_map calls, allowing users to - not include expected variables. - - Note: This is typically not used by the end user. - """ - - def __missing__(self, key): - return None - - class KeyGen: """ @@ -4804,122 +4994,1320 @@ def reset_from_form(self, frm: Form) -> None: "database": "pysimplesql_examples", } +class LazyTable(sg.Table): -# ------------------------------------------------------------------------------------- -# CONVENIENCE FUNCTIONS -# ------------------------------------------------------------------------------------- -# Convenience functions aide in building PySimpleGUI interfaces -# that work well with pysimplesql. -# TODO: How to save Form in metadata? Perhaps give forms names and reference them? -# For example - give forms names! and reference them by name string -# They could even be converted later to a real form during form creation? + """ + The LazyTable is a subclass of sg.Table for improved performance by loading rows + lazily during scroll events. Updating a sg.Table is generally fast, but with large + DataSets that contain thousands of rows, there may be some noticeable lag. LazyTable + overcomes this by only inserting a slice of rows during an `update()`. + + To use, simply replace `sg.Table` with `ss.LazyTable` as the `element` argument in a + selector() function call in your layout. + + Expects values in the form of [TableRow(pk, values)], and only becomes active after + a update(values=, selected_rows=[int]) call. Please note that LazyTable does not + support the `sg.Table` `row_colors` argument. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.values = [] # full set of rows + self.data = [] # lazy slice of rows + self.Values = self.data + + self.insert_qty = max(self.NumRows, 100) + """Number of rows to insert during an `update(values=)` and scroll events""" + + self._start_index = 0 + self._end_index = 0 + self._start_alt_color = False + self._end_alt_color = False + self._finalized = False + self._lock = threading.Lock() + self._bg = None + self._fg = None + + def update( + self, + values=None, + num_rows=None, + visible=None, + select_rows=None, + alternating_row_color=None, + ): + # check if we shouldn't be doing this update + # PySimpleGUI version support (PyPi version doesn't support quick_check) + if sg.__version__.split(".")[0] == "5": + quick_check = "quick_check=True" + elif sg.__version__.split(".")[0] == "4": + if sg.__version__.split(".")[1] == "61": + quick_check = "quick_check=True" + else: + quick_check = "" -# This is a dummy class for documenting convenience functions -class Convenience: + if not self._widget_was_created() or ( + self.ParentForm is not None and self.ParentForm.is_closed(quick_check) + ): + return - """ - Convenience functions are a collection of functions and classes that aide in - building PySimpleGUI layouts that conform to pysimplesql standards so that your - database application is up and running quickly, and with all the great automatic - functionality pysimplesql has to offer. See the documentation for the following - convenience functions: `field()`, `selector()`, `actions()`, `TableHeadings`. + # update total list + self.values = values + # Update current_index with the selected index + self.current_index = select_rows[0] if select_rows else 0 + + # needed, since PySimpleGUI doesn't create tk widgets during class init + if not self._finalized: + self.widget.configure(yscrollcommand=self._handle_scroll) + self._finalized = True + + # delete all current + children = self.widget.get_children() + for i in children: + self.widget.detach(i) + self.widget.delete(i) + self.tree_ids = [] + + # background color + self._bg = ( + self.BackgroundColor + if self.BackgroundColor is not None + and self.BackgroundColor != sg.COLOR_SYSTEM_DEFAULT + else "#FFFFFF" + ) - Note: This is a dummy class that exists purely to enhance documentation and has no - use to the end user. - """ + # text color + self._fg = ( + self.TextColor + if self.TextColor is not None and self.TextColor != sg.COLOR_SYSTEM_DEFAULT + else "#000000" + ) - pass + # alternating color + if alternating_row_color is not None: + self.AlternatingRowColor = alternating_row_color + self._start_alt_color = True + + # get values to insert + if select_rows is not None: + # Slice the list to show visible rows before and after the current index + self._start_index = max(0, self.current_index - self.insert_qty) + self._end_index = min(len(values), self.current_index + self.insert_qty + 1) + self.data = values[self._start_index : self._end_index] + else: + self.data = values + # insert values + if values is not None: + # insert the rows + for row in self.data: + iid = self.widget.insert( + "", "end", text=row, iid=row.pk, values=row, tag=row.pk + ) + self._end_alt_color = self._set_colors(iid, self._end_alt_color) + self.tree_ids.append(iid) + + # handle visible + if visible is not None: + self._visible = visible + if visible: + self._pack_restore_settings(self.element_frame) + else: + self._pack_forget_save_settings(self.element_frame) + + # handle number of rows + if num_rows is not None: + self.widget.config(height=num_rows) + + # finally, select rows and make first visible + if select_rows is not None: + # Offset select_rows index for the sliced values + offset_select_rows = [i - self._start_index for i in select_rows] + if offset_select_rows and offset_select_rows[0] < len(self.data): + # select the row + self.widget.selection_set(self.tree_ids[offset_select_rows[0]]) + # Get the row iid based on the offset_select_rows index + row_iid = self.tree_ids[offset_select_rows[0]] + # and make sure its visible + self.widget.see(row_iid) + + def _handle_scroll(self, x0, x1): + if float(x0) == 0.0 and self._start_index > 0: + with self._lock: + self._handle_start_scroll() + return + if float(x1) == 1.0 and self._end_index < len(self.values): + with self._lock: + self._handle_end_scroll() + return + # else, set the scroll + self.vsb.set(x0, x1) + + def _handle_start_scroll(self): + # determine slice + num_rows = min(self._start_index, self.insert_qty) + new_start_index = max(0, self._start_index - num_rows) + new_rows = self.values[new_start_index : self._start_index] + + # insert + for row in reversed(new_rows): + iid = self.widget.insert( + "", "0", text=row, iid=row.pk, values=row, tag=row.pk + ) + self._start_alt_color = self._set_colors(iid, self._start_alt_color) + self.tree_ids.insert(0, iid) + + # set new start + self._start_index = new_start_index + + # Insert new_rows to beginning + # don't use data.insert(0, new_rows), it breaks TableRow + self.data[:0] = new_rows + + # to avoid an infinite scroll, move scroll a little after 0.0 + with contextlib.suppress(IndexError): + row_iid = self.tree_ids[self.insert_qty + self.NumRows - 1] + self.widget.see(row_iid) + + def _handle_end_scroll(self): + num_rows = len(self.values) + # determine slice + start_index = max(0, self._end_index) + end_index = min(self._end_index + self.insert_qty, num_rows) + new_rows = self.values[start_index:end_index] + + # insert + for row in new_rows: + iid = self.widget.insert( + "", "end", text=row, iid=row.pk, values=row, tag=row.pk + ) + self._end_alt_color = self._set_colors(iid, self._end_alt_color) + self.tree_ids.append(iid) -def field( - field: str, - element: Type[sg.Element] = sg.I, - size: Tuple[int, int] = None, - label: str = "", - no_label: bool = False, - label_above: bool = False, - quick_editor: bool = True, - filter=None, - key=None, - use_ttk_buttons=None, - pad=None, - **kwargs, -) -> sg.Column: - """ - Convenience function for adding PySimpleGUI elements to the Window, so they are - properly configured for pysimplesql. The automatic functionality of pysimplesql - relies on accompanying metadata so that the `Form.auto_add_elements()` can pick them - up. This convenience function will create a text label, along with an element with - the above metadata already set up for you. Note: The element key will default to the - record name if none is supplied. See `set_label_size()`, `set_element_size()` and - `set_mline_size()` for setting default sizes of these elements. + # set new end + self._end_index = end_index - :param field: The database record in the form of table.column I.e. 'Journal.entry' - :param element: (optional) The element type desired (defaults to PySimpleGUI.Input) - :param size: Overrides the default element size that was set with - `set_element_size()` for this element only. - :param label: The text/label will automatically be generated from the column name. - If a different text/label is desired, it can be specified here. - :param no_label: Do not automatically generate a label for this element - :param label_above: Place the label above the element instead of to the left. - :param quick_editor: For records that reference another table, place a quick edit - button next to the element - :param filter: Can be used to reference different `Form`s in the same layout. Use a - matching filter when creating the `Form` with the filter parameter. - :param key: (optional) The key to give this element. See note above about the - default auto generated key. - :param kwargs: Any additional arguments will be passed to the PySimpleGUI element. - :returns: Element(s) to be used in the creation of PySimpleGUI layouts. Note that - this function actually creates multiple Elements wrapped in a PySimpleGUI - Column, but can be treated as a single Element. + # Extend self.data with new_rows + self.data.extend(new_rows) + + # to avoid an infinite scroll, move scroll a little before 1.0 + with contextlib.suppress(IndexError): + row_iid = self.tree_ids[len(self.data) - self.insert_qty] + self.widget.see(row_iid) + + def _set_colors(self, iid, toggle_color): + if self.AlternatingRowColor is not None: + if not toggle_color: + self.widget.tag_configure( + iid, background=self.AlternatingRowColor, foreground=self._fg + ) + else: + self.widget.tag_configure(iid, background=self._bg, foreground=self._fg) + toggle_color = not toggle_color + else: + self.widget.tag_configure(iid, background=self._bg, foreground=self._fg) + return toggle_color + + @property + def SelectedRows(self): + """ + Returns the selected row(s) in the LazyTable. + + :returns: + - If the LazyTable has data: + - Retrieves the index of the selected row by matching the primary key + (pk) value with the first selected item in the widget. + - Returns the corresponding row from the data list based on the index. + - If the LazyTable has no data: + - Returns None. + + :note: + This property assumes that the LazyTable is using a primary key (pk) value + to uniquely identify rows in the data list. + """ + if self.data: + index = [ + [v.pk for v in self.data].index( + [int(x) for x in self.widget.selection()][0] + ) + ][0] + return self.data[index] + return None + + def __setattr__(self, name, value): + if name == "SelectedRows": + # Handle PySimpleGui attempts to set our SelectedRows property + return + super().__setattr__(name, value) + + +class _PlaceholderText(abc.ABC): + """ + An abstract class for PySimpleGUI text-entry elements that allows for the display of + a placeholder text when the input is empty. """ - # TODO: See what the metadata does after initial setup is complete - needed anymore? - if use_ttk_buttons is None: - use_ttk_buttons = themepack.use_ttk_buttons - if pad is None: - pad = themepack.quick_editor_button_pad + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.normal_color = None + self.normal_font = None + self.placeholder_text = "" + self.placeholder_color = None + self.placeholder_font = None + self.active_placeholder = True + # fmt: off + self._non_keys = ["Control_L","Control_R","Alt_L","Alt_R","Shift_L","Shift_R", + "Caps_Lock","Return","Escape","Tab","BackSpace","Up","Down","Left", + "Right","Home","End","Page_Up","Page_Down","F1","F2","F3","F4","F5", + "F6","F7","F8","F9","F10","F11","F12", "Delete"] + # fmt: on - # if Record imply a where clause (indicated by ?) If so, strip out the info we need - if "?" in field: - table_info, where_info = field.split("?") - label_text = ( - where_info.split("=")[1].replace("fk", "").replace("_", " ").capitalize() - + ":" - ) - else: - table_info = field - label_text = ( - table_info.split(".")[1].replace("fk", "").replace("_", " ").capitalize() - + ":" - ) - table, column = table_info.split(".") + def add_placeholder(self, placeholder: str, color: str = None, font: str = None): + """ + Adds a placeholder text to the element. - key = field if key is None else key - key = keygen.get(key) + The placeholder text is displayed in the element when the element is empty and + unfocused. When the element is clicked or focused, the placeholder text + disappears and the element becomes blank. When the element loses focus and is + still empty, the placeholder text reappears. - if "values" in kwargs: - first_param = kwargs["values"] - del kwargs["values"] # make sure we don't put it in twice - else: - first_param = "" + This function is based on the recipe by Miguel Martinez Lopez, licensed under + MIT. It has been updated to work with PySimpleGUI elements. - if element.__name__ == "Multiline": - layout_element = element( - first_param, - key=key, - size=size or themepack.default_mline_size, - metadata={ - "type": TYPE_RECORD, - "Form": None, - "filter": filter, - "field": field, - "data_key": key, - }, - **kwargs, - ) - else: + :param placeholder: The text to display as placeholder when the input is empty. + :param color: The color of the placeholder text (default None). + :param font: The font of the placeholder text (default None). + """ + normal_color = self.widget.cget("fg") + normal_font = self.widget.cget("font") + + if font is None: + font = normal_font + + self.normal_color = normal_color + self.normal_font = normal_font + self.placeholder_color = color + self.placeholder_font = font + self.placeholder_text = placeholder + self.active_placeholder = True + + self._add_binds() + + @abc.abstractmethod + def _add_binds(self): + pass + + def update(self, *args, **kwargs): + """ + Updates the input widget with a new value and displays the placeholder text if + the value is empty. + + :param args: Optional arguments to pass to `sg.Element.update`. + :param kwargs: Optional keyword arguments to pass to `sg.Element.update`. + """ + if "value" in kwargs and kwargs["value"] is not None: + # If the value is not None, use it as the new value + value = kwargs.pop("value", None) + elif len(args) > 0 and args[0] is not None: + # If the value is passed as an argument, use it as the new value + value = args[0] + # Remove the value argument from args + args = args[1:] + else: + # Otherwise, use the current value + value = self.get() + + if self.active_placeholder and value: + # Replace the placeholder with the new value + super().update(value=value) + self.active_placeholder = False + self.Widget.config(fg=self.normal_color, font=self.normal_font) + elif not value: + # If the value is empty, reinsert the placeholder + super().update(value=self.placeholder_text, *args, **kwargs) + self.active_placeholder = True + self.Widget.config(fg=self.placeholder_color, font=self.placeholder_font) + else: + super().update(*args, **kwargs) + + def get(self) -> str: + """ + Returns the current value of the input, or an empty string if the input displays + the placeholder text. + + :return: The current value of the input. + """ + if self.active_placeholder: + return "" + return super().get() + + +class _EnhancedInput(_PlaceholderText, sg.Input): + """ + An Input that allows for the display of a placeholder text when empty. + """ + + def __init__(self, *args, **kwargs): + self.binds = {} + super().__init__(*args, **kwargs) + + def _add_binds(self): + widget = self.widget + if self.binds: + # remove any existing binds + for event, funcid in self.binds.items(): + self.widget.unbind(event, funcid) + self.binds = {} + + def on_key(event): + if self.active_placeholder and widget.get() == self.placeholder_text: + # dont clear for non-text-producing keys + if event.keysym in self._non_keys and widget.index(tk.INSERT) in [0, 1]: + return + # Clear the placeholder when the user starts typing + widget.delete(0, "end") + widget.config(fg=self.normal_color, font=self.normal_font) + self.active_placeholder = False + + # insert placeholder when: + # 1) widget is empty + # 2) user hits backspace and only 1 character left + # 3) or they have selected all their text and pressed backspace/delete + elif ( + (not self.active_placeholder and not widget.get()) + or (event.keysym == "BackSpace" and len(widget.get()) == 1) + or ( + event.keysym in ["BackSpace", "Delete"] + and widget.select_present() + and widget.selection_get() == widget.get() + ) + ): + with contextlib.suppress(tk.TclError): + enable_placeholder() + widget.icursor(0) + + def on_focusin(event): + if self.active_placeholder: + # Move cursor to the beginning if the field has a placeholder + widget.icursor(0) + + def on_focusout(event): + if not widget.get(): + enable_placeholder() + + def enable_placeholder(): + widget.delete(0, "end") + widget.insert(0, self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) + self.active_placeholder = True + + def disable_placeholder_select(event): + # Disable selecting the placeholder + if self.active_placeholder: + return "break" + return None + + self.binds[""] = widget.bind("", on_key, "+") + self.binds[""] = widget.bind("", on_focusin, "+") + self.binds[""] = widget.bind("", on_focusout, "+") + for event in ["<>", "", ""]: + self.binds[event] = widget.bind(event, disable_placeholder_select, "+") + + if not widget.get(): + enable_placeholder() + + +class _EnhancedMultiline(_PlaceholderText, sg.Multiline): + """ + A Multiline that allows for the display of a placeholder text when focus-out empty. + """ + + def __init__(self, *args, **kwargs): + self.binds = {} + super().__init__(*args, **kwargs) + + def _add_binds(self): + widget = self.widget + if self.binds: + for event, bind in self.binds.items(): + self.widget.unbind(event, bind) + self.binds = {} + + def on_focusin(event): + if self.active_placeholder: + widget.delete("1.0", "end") + widget.config(fg=self.normal_color, font=self.normal_font) + + self.active_placeholder = False + + def on_focusout(event): + if not widget.get("1.0", "end-1c").strip(): + widget.insert("1.0", self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) + + self.active_placeholder = True + + if not widget.get("1.0", "end-1c").strip() and self.active_placeholder: + widget.insert("1.0", self.placeholder_text) + widget.config(fg=self.placeholder_color, font=self.placeholder_font) + + self.binds[""] = widget.bind("", on_focusin, "+") + self.binds[""] = widget.bind("", on_focusout, "+") + + +def _autocomplete_combo(widget, completion_list, delta=0): + """Perform autocompletion on a Combobox widget based on the current input.""" + if delta: + # Delete text from current position to end + widget.delete(widget.position, tk.END) + else: + # Set the position to the length of the current input text + widget.position = len(widget.get()) + + prefix = widget.get().lower() + hits = [ + element for element in completion_list if element.lower().startswith(prefix) + ] + # Create a list of elements that start with the lowercase prefix + + if hits: + closest_match = min(hits, key=len) + if prefix != closest_match.lower(): + # Insert the closest match at the beginning, move the cursor to the end + widget.delete(0, tk.END) + widget.insert(0, closest_match) + widget.icursor(len(closest_match)) + + # Highlight the remaining text after the closest match + widget.select_range(widget.position, tk.END) + + if len(hits) == 1 and closest_match.lower() != prefix: + # If there is only one hit and it's not equal to the lowercase prefix, + # open dropdown + widget.event_generate("") + widget.event_generate("<>") + + else: + # If there are no hits, move the cursor to the current position + widget.icursor(widget.position) + + return hits + + +class _AutocompleteCombo(sg.Combo): + """Customized Combo widget with autocompletion feature. + + Please note that due to how PySimpleSql initilizes widgets, you must call update() + once to activate autocompletion, eg `window['combo_key'].update(values=values)` + """ + + def __init__(self, *args, **kwargs): + """Initialize the Combo widget.""" + self._completion_list = [] + self._hits = [] + self._hit_index = 0 + self.position = 0 + self.finalized = False + + super().__init__(*args, **kwargs) + + def update(self, *args, **kwargs): + """Update the Combo widget with new values.""" + if "values" in kwargs and kwargs["values"] is not None: + self._completion_list = [str(row) for row in kwargs["values"]] + if not self.finalized: + self.Widget.bind("", self.handle_keyrelease, "+") + self._hits = [] + self._hit_index = 0 + self.position = 0 + super().update(*args, **kwargs) + + def autocomplete(self, delta=0): + """Perform autocompletion based on the current input.""" + self._hits = _autocomplete_combo(self.Widget, self._completion_list, delta) + self._hit_index = 0 + + def handle_keyrelease(self, event): + """Handle key release event for autocompletion and navigation.""" + if event.keysym == "BackSpace": + self.Widget.delete(self.Widget.position, tk.END) + self.position = self.Widget.position + if event.keysym == "Left": + if self.position < self.Widget.index(tk.END): + self.Widget.delete(self.position, tk.END) + else: + self.position -= 1 + self.Widget.delete(self.position, tk.END) + if event.keysym == "Right": + self.position = self.Widget.index(tk.END) + if event.keysym == "Return": + self.Widget.icursor(tk.END) + self.Widget.selection_clear() + return + + if len(event.keysym) == 1: + self.autocomplete() + + +class _TtkCombo(ttk.Combobox): + """Customized Combo widget with autocompletion feature.""" + + def __init__(self, *args, **kwargs): + """Initialize the Combo widget.""" + self._completion_list = [str(row) for row in kwargs["values"]] + self._hits = [] + self._hit_index = 0 + self.position = 0 + self.finalized = False + + super().__init__(*args, **kwargs) + + def autocomplete(self, delta=0): + """Perform autocompletion based on the current input.""" + self._hits = _autocomplete_combo(self, self._completion_list, delta) + self._hit_index = 0 + + def handle_keyrelease(self, event): + """Handle key release event for autocompletion and navigation.""" + if event.keysym == "BackSpace": + self.delete(self.position, tk.END) + self.position = self.position + if event.keysym == "Left": + if self.position < self.index(tk.END): + self.delete(self.position, tk.END) + else: + self.position -= 1 + self.delete(self.position, tk.END) + if event.keysym == "Right": + self.position = self.index(tk.END) + if event.keysym == "Return": + self.icursor(tk.END) + self.selection_clear() + return + + if len(event.keysym) == 1: + self.autocomplete() + + +class _TtkCalendar(ttk.Frame): + """Internal Class.""" + + # Modified from Tkinter GUI Application Development Cookbook, MIT License. + + def __init__(self, master, init_date, textvariable, **kwargs): + # TODO, set these in themepack? + fwday = kwargs.pop("firstweekday", calendar.MONDAY) + sel_bg = kwargs.pop("selectbackground", "#ecffc4") + sel_fg = kwargs.pop("selectforeground", "#05640e") + + super().__init__(master, class_="ttkcalendar", **kwargs) + + self.master = master + self.cal_date = init_date + self.textvariable = textvariable + self.cal = calendar.TextCalendar(fwday) + self.font = tkfont.Font(self) + self.header = self.create_header() + self.table = self.create_table() + self.canvas = self.create_canvas(sel_bg, sel_fg) + self.build_calendar() + + def create_header(self): + left_arrow = {"children": [("Button.leftarrow", None)]} + right_arrow = {"children": [("Button.rightarrow", None)]} + style = ttk.Style(self) + style.layout("L.TButton", [("Button.focus", left_arrow)]) + style.layout("R.TButton", [("Button.focus", right_arrow)]) + + hframe = ttk.Frame(self) + btn_left = ttk.Button( + hframe, style="L.TButton", command=lambda: self.move_month(-1) + ) + btn_right = ttk.Button( + hframe, style="R.TButton", command=lambda: self.move_month(1) + ) + label = ttk.Label(hframe, width=15, anchor="center") + + hframe.pack(pady=5, anchor=tk.CENTER) + btn_left.grid(row=0, column=0) + label.grid(row=0, column=1, padx=12) + btn_right.grid(row=0, column=2) + return label + + def create_table(self): + cols = self.cal.formatweekheader(3).split() + table = ttk.Treeview(self, show="", selectmode="none", height=7, columns=cols) + table.bind("", self.minsize, "+") + table.pack(expand=1, fill=tk.BOTH) + table.tag_configure("header", background="grey90") + table.insert("", tk.END, values=cols, tag="header") + for _ in range(6): + table.insert("", tk.END) + + width = max(map(self.font.measure, cols)) + for col in cols: + table.column(col, width=width, minwidth=width, anchor=tk.E) + return table + + def create_canvas(self, bg, fg): + canvas = tk.Canvas( + self.table, background=bg, borderwidth=1, highlightthickness=0 + ) + canvas.text = canvas.create_text(0, 0, fill=fg, anchor=tk.W) + self.table.bind("", self.pressed_callback, "+") + return canvas + + def build_calendar(self): + year, month = self.cal_date.year, self.cal_date.month + month_name = self.cal.formatmonthname(year, month, 0) + month_weeks = self.cal.monthdayscalendar(year, month) + + self.header.config(text=month_name.title()) + items = self.table.get_children()[1:] + for week, item in itertools.zip_longest(month_weeks, items): + fmt_week = [f"{day:02d}" if day else "" for day in (week or [])] + self.table.item(item, values=fmt_week) + + def pressed_callback(self, event): + x, y, widget = event.x, event.y, event.widget + item = widget.identify_row(y) + column = widget.identify_column(x) + items = self.table.get_children()[1:] + + if not column or item not in items: + # clicked te header or outside the columns + return + + index = int(column[1]) - 1 + values = widget.item(item)["values"] + text = values[index] if len(values) else None + bbox = widget.bbox(item, column) + if bbox and text: + self.cal_date = dt.date(self.cal_date.year, self.cal_date.month, int(text)) + self.draw_selection(bbox) + self.textvariable.set(self.cal_date.strftime("%Y-%m-%d")) + + def draw_selection(self, bbox): + canvas, text = self.canvas, "%02d" % self.cal_date.day + x, y, width, height = bbox + textw = self.font.measure(text) + canvas.configure(width=width, height=height) + canvas.coords(canvas.text, width - textw, height / 2 - 1) + canvas.itemconfigure(canvas.text, text=text) + canvas.place(x=x, y=y) + + def set_date(self, dateobj): + self.cal_date = dateobj + self.canvas.place_forget() + self.build_calendar() + + def select_date(self): + bbox = self.get_bbox_for_date(self.cal_date) + if bbox: + self.draw_selection(bbox) + + def get_bbox_for_date(self, new_date): + items = self.table.get_children()[1:] + for item in items: + values = self.table.item(item)["values"] + for i, value in enumerate(values): + if isinstance(value, int) and value == new_date.day: + column = "#{}".format(i + 1) + self.table.update() + return self.table.bbox(item, column) + return None + + def move_month(self, offset): + self.canvas.place_forget() + month = self.cal_date.month - 1 + offset + year = self.cal_date.year + month // 12 + month = month % 12 + 1 + self.cal_date = dt.date(year, month, 1) + self.build_calendar() + + def minsize(self, e): + width, height = self.master.geometry().split("x") + height = height[: height.index("+")] + self.master.minsize(width, height) + + +class _DatePicker(ttk.Entry): + def __init__(self, master, frm_reference, init_date, **kwargs): + self.frm = frm_reference + textvariable = kwargs["textvariable"] + self.calendar = _TtkCalendar(self.frm.window.TKroot, init_date, textvariable) + self.calendar.place_forget() + self.button = ttk.Button(master, text="▼", width=2, command=self.show_calendar) + super().__init__(master, class_="Datepicker", **kwargs) + + self.bind("", self.on_entry_key_release, "+") + self.calendar.bind("", self.hide_calendar, "+") + + def show_calendar(self, event=None): + self.configure(state="disabled") + self.calendar.place(in_=self, relx=0, rely=1) + self.calendar.focus_force() + self.calendar.select_date() + + def hide_calendar(self, event=None): + self.configure(state="!disabled") + self.calendar.place_forget() + self.focus_force() + + def on_entry_key_release(self, event=None): + # Check if the user has typed a valid date + try: + date_str = self.get() + date = dt.datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + return + + # Update the calendar to show the new date + self.calendar.set_date(date) + + +class _CellEdit: + + """Internal class used when sg.Table cells are double-clicked if edit enabled""" + + def __init__(self, frm_reference: Form): + self.frm = frm_reference + self.active_edit = False + + def __call__(self, event): + # if double click a treeview + if isinstance(event.widget, ttk.Treeview): + tk_widget = event.widget + # identify region + region = tk_widget.identify("region", event.x, event.y) + if region == "cell": + self.edit(event) + + def edit(self, event): + treeview = event.widget + + # only allow 1 edit at a time + if self.active_edit or self.frm._edit_protect: + return + + # get row and column + row = int(treeview.identify_row(event.y)) + col_identified = treeview.identify_column(event.x) + if col_identified: + col_idx = int(treeview.identify_column(event.x)[1:]) - 1 + + try: + data_key, element = self.get_datakey_and_sgtable(treeview, self.frm) + except TypeError: + return + + if not element: + return + + # get table_headings + table_heading = element.metadata["TableHeading"] + + # get column name + columns = table_heading.columns() + column = columns[col_idx - 1] + + # use table_element to distinguish + table_element = element.Widget + root = table_element.master + + # get cell text, coordinates, width and height + text = table_element.item(row, "values")[col_idx] + x, y, width, height = table_element.bbox(row, col_idx) + + # return early due to following conditions: + if col_idx == 0: + return + + if column in table_heading.readonly_columns: + logger.debug(f"{column} is readonly") + return + + if column == self.frm[data_key].pk_column: + logger.debug(f"{column} is pk_column") + return + + if self.frm[data_key].column_info[column]["generated"]: + logger.debug(f"{column} is a generated column") + return + + if not table_heading.edit_enable: + logger.debug("This Table element does not allow editing") + return + + # else, we can continue: + self.active_edit = True + + # see if we should use a combobox + combobox_values = self.frm[data_key].combobox_values( + column, insert_placeholder=False + ) + + if combobox_values: + widget_type = TK_COMBOBOX + width = ( + width + if width >= themepack.combobox_min_width + else themepack.combobox_min_width + ) + + # or a checkbox + elif self.frm[data_key].column_info[column]["domain"] in ["BOOLEAN"]: + widget_type = TK_CHECKBUTTON + width = ( + width + if width >= themepack.checkbox_min_width + else themepack.checkbox_min_width + ) + + # or a date + elif self.frm[data_key].column_info[column]["domain"] in ["DATE"]: + text = self.frm[data_key].column_info[column].cast(text) + widget_type = TK_DATEPICKER + width = ( + width + if width >= themepack.datepicker_min_width + else themepack.datepicker_min_width + ) + + # else, its a normal ttk.entry + else: + widget_type = TK_ENTRY + width = ( + width if width >= themepack.text_min_width else themepack.text_min_width + ) + + # float a frame over the cell + frame = tk.Frame(root) + frame.place(x=x, y=y, anchor="nw", width=width, height=height) + + # setup the widgets + # ------------------ + + # checkbutton + # need to use tk.IntVar for checkbox + if widget_type == TK_CHECKBUTTON: + field_var = tk.BooleanVar() + field_var.set(checkbox_to_bool(text)) + self.field = tk.Checkbutton(frame, variable=field_var) + expand = False + else: + # create tk.StringVar for combo/entry + field_var = tk.StringVar() + field_var.set(text) + + # entry + if widget_type == TK_ENTRY: + self.field = ttk.Entry(frame, textvariable=field_var, justify="left") + expand = True + + if widget_type == TK_DATEPICKER: + text = dt.date.today() if type(text) is str else text + self.field = _DatePicker( + frame, self.frm, init_date=text, textvariable=field_var + ) + expand = True + + # combobox + if widget_type == TK_COMBOBOX: + self.field = _TtkCombo( + frame, textvariable=field_var, justify="left", values=combobox_values + ) + self.field.bind("", self.combo_configure) + expand = True + + # bind text to Return (for save), and Escape (for discard) + # event is discarded + accept_dict = { + "data_key": data_key, + "table_element": table_element, + "row": row, + "column": column, + "col_idx": col_idx, + "combobox_values": combobox_values, + "widget_type": widget_type, + "field_var": field_var, + } + + self.field.bind( + "", + lambda event: self.accept(**accept_dict), + ) + self.field.bind("", lambda event: self.destroy()) + + if themepack.use_cell_buttons: + # buttons + self.accept_button = tk.Button( + frame, + text="\u2714", + foreground="green", + relief=tk.GROOVE, + command=lambda: self.accept(**accept_dict), + ) + self.cancel_button = tk.Button( + frame, + text="\u274E", + foreground="red", + relief=tk.GROOVE, + command=lambda: self.destroy(), + ) + # pack buttons + self.cancel_button.pack(side="right") + self.accept_button.pack(side="right") + + if widget_type == TK_DATEPICKER: + self.field.button.pack(side="right") + # have entry use remaining space + self.field.pack(side="left", expand=expand, fill="both") + + # select text and focus to begin with + if widget_type != TK_CHECKBUTTON: + self.field.select_range(0, tk.END) + self.field.focus_force() + + if widget_type == TK_COMBOBOX: + self.field.bind("", self.field.handle_keyrelease, "+") + + # bind single-clicks + self.destroy_bind = self.frm.window.TKroot.bind( + "", + lambda event: self.single_click_callback(event, accept_dict), + "+", + ) + + def accept( + self, + data_key, + table_element, + row, + column, + col_idx, + combobox_values: ElementRow, + widget_type, + field_var, + ): + # get current entry text + new_value = field_var.get() + + # get current table row + values = list(table_element.item(row, "values")) + + # if combo, set the value to the parent pk + if widget_type == TK_COMBOBOX: + new_value = combobox_values[self.field.current()].get_pk() + + dataset = self.frm[data_key] + + # see if there was a change + old_value = dataset.get_current_row().copy()[column] + cast_new_value = dataset.value_changed( + column, old_value, new_value, bool(widget_type == TK_CHECKBUTTON) + ) + if cast_new_value is not Boolean.FALSE: + # push row to dataset and update + dataset.set_current(column, cast_new_value, write_event=True) + # Update matching field + self.frm.update_fields(data_key, columns=[column]) + # TODO: make sure we actually want to set new_value to cast + new_value = cast_new_value + + # now we can update the GUI table + # ------------------------------- + + # if combo, set new_value to actual text (not pk) + if widget_type == TK_COMBOBOX: + new_value = combobox_values[self.field.current()] + + # if boolean, set + if widget_type == TK_CHECKBUTTON and themepack.display_boolean_as_checkbox: + new_value = ( + themepack.checkbox_true + if checkbox_to_bool(new_value) + else themepack.checkbox_false + ) + + # update value row with new text + values[col_idx] = new_value + + # set marker + values[0] = ( + themepack.marker_unsaved + if dataset.current_row_has_backup + and not dataset.get_current_row().equals(dataset.get_original_current_row()) + else " " + ) + + # push changes to table element row + table_element.item(row, values=values) + + self.destroy() + + def destroy(self): + # unbind + self.frm.window.TKroot.unbind("", self.destroy_bind) + + # destroy widets and window + self.field.destroy() + if themepack.use_cell_buttons: + self.accept_button.destroy() + self.cancel_button.destroy() + self.field.master.destroy() + # reset edit + self.active_edit = False + + def single_click_callback( + self, + event, + accept_dict, + ): + # destroy if you click a heading while editing + if isinstance(event.widget, ttk.Treeview): + tk_widget = event.widget + # identify region + region = tk_widget.identify("region", event.x, event.y) + if region == "heading": + self.destroy() + return + # disregard if you click the field/buttons of celledit + widget_list = [self.field] + if themepack.use_cell_buttons: + widget_list.append(self.accept_button) + widget_list.append(self.cancel_button) + + # for datepicker + with contextlib.suppress(AttributeError): + widget_list.append(self.field.button) + if "ttkcalendar" in str(event.widget): + return + + if event.widget in widget_list: + return + self.accept(**accept_dict) + + def get_datakey_and_sgtable(self, treeview, frm): + # loop through datasets, trying to identify sg.Table selector + for data_key in [ + data_key for data_key in frm.datasets if len(frm[data_key].selector) + ]: + for e in frm[data_key].selector: + element = e["element"] + if element.widget == treeview and "TableHeading" in element.metadata: + return data_key, element + return None + + def combo_configure(self, event): + """Configures combobox drop-down to be at least as wide as longest value""" + + combo = event.widget + style = ttk.Style() + + # get longest value + long = max(combo.cget("values"), key=len) + # get font + font = tkfont.nametofont(str(combo.cget("font"))) + # set initial width + width = font.measure(long.strip() + "0") + # make it width size if smaller + width = width if width > combo["width"] else combo["width"] + style.configure("SS.TCombobox", postoffset=(0, 0, width, 0)) + combo.configure(style="SS.TCombobox") + + +class _LiveUpdate: + + """Internal class used to automatically sync selectors with field changes""" + + def __init__(self, frm_reference: Form): + self.frm = frm_reference + self.last_event_widget = None + self.last_event_time = None + self.delay_seconds = 0.25 + + def __call__(self, event): + # keep track of time on same widget + if event.widget == self.last_event_widget: + self.last_event_time = time() + self.last_event_widget = event.widget + + # get widget type + widget_type = event.widget.__class__.__name__ + + # if <> and a combobox, or a checkbutton + if ( + event.type == TK_COMBOBOX_SELECTED and widget_type == TK_COMBOBOX + ) or widget_type == TK_CHECKBUTTON: + self.sync(event.widget, widget_type) + + # use tk.after() for text, so waits for pause in typing to update selector. + elif widget_type in [TK_ENTRY, TK_TEXT]: + self.frm.window.TKroot.after( + int(self.delay_seconds * 1000), + lambda: self.delay(event.widget, widget_type), + ) + + def sync(self, widget, widget_type): + for e in self.frm.element_map: + if e["element"].widget == widget: + data_key = e["table"] + column = e["column"] + element = e["element"] + if widget_type == TK_COMBOBOX and isinstance(element.get(), ElementRow): + new_value = element.get().get_pk() + else: + new_value = element.get() + + dataset = self.frm[data_key] + + # get cast new value to correct type + for col in dataset.column_info: + if col["name"] == column: + new_value = col.cast(new_value) + break + + # see if there was a change + old_value = dataset.get_current_row()[column] + new_value = dataset.value_changed( + column, old_value, new_value, bool(widget_type == TK_CHECKBUTTON) + ) + if new_value is not Boolean.FALSE: + # push row to dataset and update + dataset.set_current(column, new_value, write_event=True) + + # Update tableview if uses column: + if dataset.column_likely_in_selector(column): + self.frm.update_selectors(dataset.key) + + def delay(self, widget, widget_type): + if self.last_event_time: + elapsed_sec = time() - self.last_event_time + if elapsed_sec >= self.delay_seconds: + self.sync(widget, widget_type) + else: + self.sync(widget, widget_type) + + +# ------------------------------------------------------------------------------------- +# CONVENIENCE FUNCTIONS +# ------------------------------------------------------------------------------------- +# Convenience functions aide in building PySimpleGUI interfaces +# that work well with pysimplesql. +# TODO: How to save Form in metadata? Perhaps give forms names and reference them? +# For example - give forms names! and reference them by name string +# They could even be converted later to a real form during form creation? + + +# This is a dummy class for documenting convenience functions +class Convenience: + + """ + Convenience functions are a collection of functions and classes that aide in + building PySimpleGUI layouts that conform to pysimplesql standards so that your + database application is up and running quickly, and with all the great automatic + functionality pysimplesql has to offer. See the documentation for the following + convenience functions: `field()`, `selector()`, `actions()`, `TableHeadings`. + + Note: This is a dummy class that exists purely to enhance documentation and has no + use to the end user. + """ + + pass + + +def field( + field: str, + element: Type[sg.Element] = _EnhancedInput, + size: Tuple[int, int] = None, + label: str = "", + no_label: bool = False, + label_above: bool = False, + quick_editor: bool = True, + filter=None, + key=None, + use_ttk_buttons=None, + pad=None, + **kwargs, +) -> sg.Column: + """ + Convenience function for adding PySimpleGUI elements to the Window, so they are + properly configured for pysimplesql. The automatic functionality of pysimplesql + relies on accompanying metadata so that the `Form.auto_add_elements()` can pick them + up. This convenience function will create a text label, along with an element with + the above metadata already set up for you. Note: The element key will default to the + record name if none is supplied. See `set_label_size()`, `set_element_size()` and + `set_mline_size()` for setting default sizes of these elements. + + :param field: The database record in the form of table.column I.e. 'Journal.entry' + :param element: (optional) The element type desired (defaults to PySimpleGUI.Input) + :param size: Overrides the default element size that was set with + `set_element_size()` for this element only. + :param label: The text/label will automatically be generated from the column name. + If a different text/label is desired, it can be specified here. + :param no_label: Do not automatically generate a label for this element + :param label_above: Place the label above the element instead of to the left. + :param quick_editor: For records that reference another table, place a quick edit + button next to the element + :param filter: Can be used to reference different `Form`s in the same layout. Use a + matching filter when creating the `Form` with the filter parameter. + :param key: (optional) The key to give this element. See note above about the + default auto generated key. + :param kwargs: Any additional arguments will be passed to the PySimpleGUI element. + :returns: Element(s) to be used in the creation of PySimpleGUI layouts. Note that + this function actually creates multiple Elements wrapped in a PySimpleGUI + Column, but can be treated as a single Element. + """ + # TODO: See what the metadata does after initial setup is complete - needed anymore? + element = _EnhancedInput if element == sg.Input else element + element = _EnhancedMultiline if element == sg.Multiline else element + element = _AutocompleteCombo if element == sg.Combo else element + + if use_ttk_buttons is None: + use_ttk_buttons = themepack.use_ttk_buttons + if pad is None: + pad = themepack.quick_editor_button_pad + + # if Record imply a where clause (indicated by ?) If so, strip out the info we need + if "?" in field: + table_info, where_info = field.split("?") + label_text = ( + where_info.split("=")[1].replace("fk", "").replace("_", " ").capitalize() + + ":" + ) + else: + table_info = field + label_text = ( + table_info.split(".")[1].replace("fk", "").replace("_", " ").capitalize() + + ":" + ) + table, column = table_info.split(".") + + key = field if key is None else key + key = keygen.get(key) + + if "values" in kwargs: + first_param = kwargs["values"] + del kwargs["values"] # make sure we don't put it in twice + else: + first_param = "" + + if element == _EnhancedMultiline: + layout_element = element( + first_param, + key=key, + size=size or themepack.default_mline_size, + metadata={ + "type": TYPE_RECORD, + "Form": None, + "filter": filter, + "field": field, + "data_key": key, + }, + **kwargs, + ) + else: layout_element = element( first_param, key=key, @@ -4933,7 +6321,7 @@ def field( }, **kwargs, ) - layout_label = sg.T( + layout_label = sg.Text( label if label else label_text, size=themepack.default_label_size, key=f"{key}:label", @@ -4952,22 +6340,14 @@ def field( ], pad=(0, 0), ) - if element.__name__ == "Text": # don't show markers for sg.Text - if no_label: - layout = [[layout_element]] - elif label_above: - layout = [[layout_label], [layout_element]] - else: - layout = [[layout_label, layout_element]] + if no_label: + layout = [[layout_marker, layout_element]] + elif label_above: + layout = [[layout_label], [layout_marker, layout_element]] else: - if no_label: - layout = [[layout_marker, layout_element]] - elif label_above: - layout = [[layout_label], [layout_marker, layout_element]] - else: - layout = [[layout_label, layout_marker, layout_element]] + layout = [[layout_label, layout_marker, layout_element]] # Add the quick editor button where appropriate - if element == sg.Combo and quick_editor: + if element == _AutocompleteCombo and quick_editor: meta = { "type": TYPE_EVENT, "event_type": EVENT_QUICK_EDIT, @@ -5000,7 +6380,6 @@ def field( ) ) # return layout - # TODO: Does this actually need wrapped in a sg.Col??? return sg.Col(layout=layout, pad=(0, 0)) @@ -5400,7 +6779,7 @@ def actions( } if type(themepack.search) is bytes: layout += [ - sg.Input("", key=keygen.get(f"{key}search_input"), size=search_size), + _EnhancedInput("", key=keygen.get(f"{key}search_input"), size=search_size), sg.B( "", key=keygen.get(f"{key}search_button"), @@ -5415,7 +6794,7 @@ def actions( ] else: layout += [ - sg.Input("", key=keygen.get(f"{key}search_input"), size=search_size), + _EnhancedInput("", key=keygen.get(f"{key}search_input"), size=search_size), sg.B( themepack.search, key=keygen.get(f"{key}search_button"), @@ -5457,6 +6836,7 @@ def selector( :param kwargs: Any additional arguments supplied will be passed on to the PySimpleGUI element. """ + element = Combo if element == sg.Combo else element key = f"{table}:selector" if key is None else key key = keygen.get(key) @@ -5480,20 +6860,19 @@ def selector( key=key, metadata=meta, ) - elif element == sg.Combo: + elif element == _AutocompleteCombo: w = themepack.default_element_size[0] layout = element( values=(), size=size or (w, 10), - readonly=True, enable_events=True, key=key, auto_size_text=False, metadata=meta, ) - elif element == sg.Table: + elif element in [sg.Table, LazyTable]: # Check if the headings arg is a Table heading... - if kwargs["headings"].__class__.__name__ == "TableHeadings": + if isinstance(kwargs["headings"], TableHeadings): # Overwrite the kwargs from the TableHeading info kwargs["visible_column_map"] = kwargs["headings"].visible_map() kwargs["col_widths"] = kwargs["headings"].width_map() @@ -5514,21 +6893,26 @@ def selector( kwargs["select_mode"] = sg.TABLE_SELECT_MODE_BROWSE kwargs["justification"] = "left" + # Make an empty list of values + vals = [[""] * len(kwargs["headings"])] + # Create a narrow column for displaying a * character for virtual rows. - # This will have to be the 2nd column right after the pk - kwargs["headings"].insert(0, "") + # This will be the 1st column kwargs["visible_column_map"].insert(0, 1) if "col_widths" in kwargs: - kwargs["col_widths"].insert(0, 2) - - # Make an empty list of values - vals = [[""] * len(kwargs["headings"])] + kwargs["col_widths"].insert(0, themepack.unsaved_column_width) # Change the headings parameter to be a list so # the heading doesn't display dicts when it first loads # The TableHeadings instance is already stored in metadata - if kwargs["headings"].__class__.__name__ == "TableHeadings": + if isinstance(kwargs["headings"], TableHeadings): + if kwargs["headings"].save_enable: + kwargs["headings"].insert(0, themepack.unsaved_column_header) + else: + kwargs["headings"].insert(0, "") kwargs["headings"] = kwargs["headings"].heading_names() + else: + kwargs["headings"].insert(0, "") layout = element(values=vals, key=key, metadata=meta, **kwargs) else: @@ -5542,535 +6926,188 @@ class TableHeadings(list): """ This is a convenience class used to build table headings for PySimpleGUI. - In addition, `TableHeading` objects can sort columns in ascending or descending - order by clicking on the column in the heading in the PySimpleGUI Table element if - the sort_enable parameter is set to True. - """ - - # store our instances - instances = [] - - def __init__(self, sort_enable: bool = True, edit_enable: bool = False) -> None: - """ - Create a new TableHeadings object. - - :param sort_enable: True to enable sorting by heading column - :param edit_enable: True to enable editing cells. If cell editing is enabled, - any accepted edits will immediately push to the associated field element. - In addition, editing the set description column will trigger the update of - all comboboxes. - :returns: None - """ - self.sort_enable = sort_enable - self.edit_enable = edit_enable - self._width_map = [] - self._visible_map = [] - - # Store this instance in the master list of instances - TableHeadings.instances.append(self) - - def add_column( - self, column: str, heading_column: str, width: int, visible: bool = True - ) -> None: - """ - Add a new heading column to this TableHeading object. Columns are added in the - order that this method is called. Note that the primary key column does not need - to be included, as primary keys are stored internally in the `TableRow` class. - - :param heading_column: The name of this columns heading (title) - :param column: The name of the column in the database the heading column is for - :param width: The width for this column to display within the Table element - :param visible: True if the column is visible. Typically, the only hidden - column would be the primary key column if any. This is also useful if the - `DataSet.rows` DataFrame has information that you don't want to display. - :returns: None - """ - self.append({"heading": heading_column, "column": column}) - self._width_map.append(width) - self._visible_map.append(visible) - - def heading_names(self) -> List[str]: - """ - Return a list of heading_names for use with the headings parameter of - PySimpleGUI.Table. - - :returns: a list of heading names - """ - return [c["heading"] for c in self] - - def columns(self): - """ - Return a list of column names. - - :returns: a list of column names - """ - return [c["column"] for c in self if c["column"] is not None] - - def visible_map(self) -> List[Union[bool, int]]: - """ - Convenience method for creating PySimpleGUI tables. - - :returns: a list of visible columns for use with th PySimpleGUI Table - visible_column_map parameter - """ - return list(self._visible_map) - - def width_map(self) -> List[int]: - """ - Convenience method for creating PySimpleGUI tables. - - :returns: a list column widths for use with th PySimpleGUI Table col_widths - parameter - """ - return list(self._width_map) - - def update_headings( - self, element: sg.Table, sort_column=None, sort_order: int = None - ) -> None: - """ - Perform the actual update to the PySimpleGUI Table heading. - Note: Not typically called by the end user. - - :param element: The PySimpleGUI Table element - :param sort_column: The column to show the sort direction indicators on - :param sort_order: A SORT_* constant (SORT_NONE, SORT_ASC, SORT_DESC) - :returns: None - """ - - # Load in our marker characters. We will use them to both display the - # sort direction and to detect current direction - try: - asc = themepack.marker_sort_asc - except AttributeError: - asc = "\u25BC" - try: - desc = themepack.marker_sort_desc - except AttributeError: - desc = "\u25B2" - - for i, x in zip(range(len(self)), self): - # Clear the direction markers - x["heading"] = x["heading"].replace(asc, "").replace(desc, "") - if ( - x["column"] == sort_column - and sort_column is not None - and sort_order != SORT_NONE - ): - x["heading"] += asc if sort_order == SORT_ASC else desc - element.Widget.heading(i, text=x["heading"], anchor="w") - - def enable_sorting(self, element: sg.Table, fn: callable) -> None: - """ - Enable the sorting callbacks for each column index. - Note: Not typically used by the end user. Called from `Form.auto_map_elements()` - - :param element: The PySimpleGUI Table element associated with this TableHeading - :param fn: A callback functions to run when a heading is clicked. The callback - should take one column parameter. - :returns: None - """ - if self.sort_enable: - for i in range(len(self)): - if self[i]["column"] is not None: - element.widget.heading( - i, command=functools.partial(fn, self[i]["column"]) - ) - self.update_headings(element) - - def insert(self, idx, heading_column: str, column: str = None, *args, **kwargs): - super().insert(idx, {"heading": heading_column, "column": column}) - - -class _SortCallbackWrapper: - - """Internal class used when sg.Table column headers are clicked.""" - - def __init__(self, frm_reference: Form, data_key: str): - """ - Create a new _SortCallbackWrapper object. - - :param frm_reference: `Form` object - :param data_key: `DataSet` key - :returns: None - """ - self.frm: Form = frm_reference - self.data_key = data_key - - def __call__(self, column): - self.frm[self.data_key].sort_cycle(column, self.data_key, update_elements=True) - - -class _CellEdit: - - """Internal class used when sg.Table cells are double-clicked if edit enabled""" - - def __init__(self, frm_reference: Form): - self.frm = frm_reference - self.active_edit = False - - def __call__(self, event): - # if double click a treeview - if event.widget.__class__.__name__ == "Treeview": - tk_widget = event.widget - # identify region - region = tk_widget.identify("region", event.x, event.y) - if region == "cell": - self.edit(event) - - def edit(self, event): - treeview = event.widget - - # only allow 1 edit at a time - if self.active_edit or self.frm._edit_protect: - return - - # get row and column - row = int(treeview.identify_row(event.y)) - col_identified = treeview.identify_column(event.x) - if col_identified: - col_idx = int(treeview.identify_column(event.x)[1:]) - 1 - - try: - data_key, element = self.get_datakey_and_sgtable(treeview, self.frm) - except TypeError: - return - - if not element: - return - - # found a table we can edit, don't allow another double-click - self.active_edit = True - - # get table_headings - table_heading = element.metadata["TableHeading"] - - # get column name - columns = table_heading.columns() - column = columns[col_idx - 1] - - # make sure it's not the marker column or pk_column - if col_idx > 0 and column != self.frm[data_key].pk_column: - # use table_element to distinguish - table_element = element.Widget - root = table_element.master - - # get cell text, coordinates, width and height - text = table_element.item(row, "values")[col_idx] - x, y, width, height = table_element.bbox(row, col_idx) - - # see if we should use a combobox - combobox_values = self.frm[data_key].combobox_values(column) - - if combobox_values: - widget_type = TK_COMBOBOX - width = ( - width - if width >= themepack.combobox_min_width - else themepack.combobox_min_width - ) - - # or a checkbox - elif self.frm[data_key].column_info[column]["domain"] in ["BOOLEAN"]: - widget_type = TK_CHECKBUTTON - width = ( - width - if width >= themepack.checkbox_min_width - else themepack.checkbox_min_width - ) - - # else, its a normal ttk.entry - else: - widget_type = TK_ENTRY - width = ( - width - if width >= themepack.text_min_width - else themepack.text_min_width - ) - - # float a frame over the cell - frame = ttk.Frame(root) - frame.place(x=x, y=y, anchor="nw", width=width, height=height) - - # setup the widgets - # ------------------ - - # checkbutton - # need to use tk.IntVar for checkbox - if widget_type == TK_CHECKBUTTON: - field_var = tk.IntVar() - field_var.set(text) - self.field = ttk.Checkbutton(frame, variable=field_var) - else: - # create tk.StringVar for combo/entry - field_var = tk.StringVar() - field_var.set(text) - - # entry - if widget_type == TK_ENTRY: - self.field = ttk.Entry(frame, textvariable=field_var, justify="left") - - # combobox - if widget_type == TK_COMBOBOX: - self.field = ttk.Combobox(frame, textvariable=field_var, justify="left") - self.field["values"] = combobox_values - self.field.bind("", self.combo_configure) - - # bind text to Return (for save), and Escape (for discard) - # event is discarded - accept_dict = { - "data_key": data_key, - "table_element": table_element, - "row": row, - "column": column, - "col_idx": col_idx, - "combobox_values": combobox_values, - "widget_type": widget_type, - "field_var": field_var, - } - - self.field.bind( - "", - lambda event: self.accept(**accept_dict), - ) - self.field.bind("", lambda event: self.destroy()) - - if themepack.use_cell_buttons: - # buttons - self.accept_button = tk.Button( - frame, - text="\u2714", - foreground="green", - relief=tk.GROOVE, - command=lambda: self.accept(**accept_dict), - ) - self.cancel_button = tk.Button( - frame, - text="\u274E", - foreground="red", - relief=tk.GROOVE, - command=lambda: self.destroy(), - ) - # pack buttons - self.cancel_button.pack(side="right") - self.accept_button.pack(side="right") - - # have entry use remaining space - self.field.pack(side="left", expand=True, fill="both") - - # select text and focus to begin with - if widget_type != TK_CHECKBUTTON: - self.field.select_range(0, tk.END) - self.field.focus_force() - - # bind single-clicks - self.destroy_bind = self.frm.window.TKroot.bind( - "", - lambda event: self.single_click_callback(event, accept_dict), - ) - else: - # didn't find a cell we can edit - self.active_edit = False - - def accept( - self, - data_key, - table_element, - row, - column, - col_idx, - combobox_values: ElementRow, - widget_type, - field_var, - ): - # get current entry text - new_value = field_var.get() - - # get current table row - values = list(table_element.item(row, "values")) - - # if combo, set the value to the parent pk - if widget_type == TK_COMBOBOX: - new_value = combobox_values[self.field.current()].get_pk() - - dataset = self.frm[data_key] - - # see if there was a change - old_value = dataset.get_current_row().copy()[column] - cast_new_value = dataset.value_changed( - column, old_value, new_value, bool(widget_type == TK_CHECKBUTTON) - ) - if cast_new_value is not Boolean.FALSE: - # push row to dataset and update - dataset.set_current(column, cast_new_value) - # Update matching field - self.frm.update_fields(data_key, columns=[column]) - # TODO: make sure we actually want to set new_value to cast - new_value = cast_new_value - - # now we can update the GUI table - # ------------------------------- - - # if combo, set new_value to actual text (not pk) - if widget_type == TK_COMBOBOX: - new_value = combobox_values[self.field.current()] - - # update value row with new text - values[col_idx] = new_value + In addition, `TableHeading` objects can sort columns in ascending or descending + order by clicking on the column in the heading in the PySimpleGUI Table element if + the sort_enable parameter is set to True. + """ - # set marker - values[0] = ( - themepack.marker_unsaved - if dataset.current_row_has_backup - and not dataset.get_current_row().equals(dataset.get_original_current_row()) - else " " - ) + # store our instances + instances = [] - # push changes to table element row - table_element.item(row, values=values) + def __init__( + self, + sort_enable: bool = True, + edit_enable: bool = False, + save_enable: bool = False, + ) -> None: + """ + Create a new TableHeadings object. - self.destroy() + :param sort_enable: True to enable sorting by heading column + :param edit_enable: Enables cell editing if True. Accepted edits update both + `sg.Table` and associated `field` element. + :param save_enable: Enables saving record by double-clicking unsaved marker col. + :returns: None + """ + self.sort_enable = sort_enable + self.edit_enable = edit_enable + self.save_enable = save_enable + self._width_map = [] + self._visible_map = [] + self.readonly_columns = [] - def destroy(self): - # unbind - self.frm.window.TKroot.unbind("", self.destroy_bind) - # destroy widets and window - self.field.destroy() - if themepack.use_cell_buttons: - self.accept_button.destroy() - self.cancel_button.destroy() - self.field.master.destroy() - # reset edit - self.active_edit = False + # Store this instance in the master list of instances + TableHeadings.instances.append(self) - def single_click_callback( + def add_column( self, - event, - accept_dict, - ): - # destroy if you click a heading while editing - if event.widget.__class__.__name__ == "Treeview": - tk_widget = event.widget - # identify region - region = tk_widget.identify("region", event.x, event.y) - if region == "heading": - self.destroy() - return + column: str, + heading_column: str, + width: int, + visible: bool = True, + readonly: bool = False, + ) -> None: + """ + Add a new heading column to this TableHeading object. Columns are added in the + order that this method is called. Note that the primary key column does not need + to be included, as primary keys are stored internally in the `TableRow` class. - # disregard if you click the field/buttons of celledit - widget_list = [self.field] - if themepack.use_cell_buttons: - widget_list.append(self.accept_button) - widget_list.append(self.cancel_button) - if event and event.widget in widget_list: - return + :param heading_column: The name of this columns heading (title) + :param column: The name of the column in the database the heading column is for + :param width: The width for this column to display within the Table element + :param visible: True if the column is visible. Typically, the only hidden + column would be the primary key column if any. This is also useful if the + `DataSet.rows` DataFrame has information that you don't want to display. + :param readonly: Indicates if the column is read-only when + `TableHeading.edit_enable` is True. + :returns: None + """ + self.append({"heading": heading_column, "column": column}) + self._width_map.append(width) + self._visible_map.append(visible) + if readonly: + self.readonly_columns.append(column) - # otherwise, accept - self.accept(**accept_dict) + def heading_names(self) -> List[str]: + """ + Return a list of heading_names for use with the headings parameter of + PySimpleGUI.Table. - def get_datakey_and_sgtable(self, treeview, frm): - # loop through datasets, trying to identify sg.Table selector - for data_key in [ - data_key for data_key in frm.datasets if len(frm[data_key].selector) - ]: - for e in frm[data_key].selector: - element = e["element"] - if ( - element.widget == treeview - and "TableHeading" in element.metadata - and element.metadata["TableHeading"].edit_enable - ): - return data_key, element - return None + :returns: a list of heading names + """ + return [c["heading"] for c in self] - def combo_configure(self, event): - """Configures combobox drop-down to be at least as wide as longest value""" + def columns(self): + """ + Return a list of column names. - combo = event.widget - style = ttk.Style() + :returns: a list of column names + """ + return [c["column"] for c in self if c["column"] is not None] - # get longest value - long = max(combo.cget("values"), key=len) - # get font - font = tkfont.nametofont(str(combo.cget("font"))) - # set initial width - width = font.measure(long.strip() + "0") - # make it width size if smaller - width = width if width > combo["width"] else combo["width"] - style.configure("SS.TCombobox", postoffset=(0, 0, width, 0)) - combo.configure(style="SS.TCombobox") + def visible_map(self) -> List[Union[bool, int]]: + """ + Convenience method for creating PySimpleGUI tables. + :returns: a list of visible columns for use with th PySimpleGUI Table + visible_column_map parameter + """ + return list(self._visible_map) -class _LiveUpdate: + def width_map(self) -> List[int]: + """ + Convenience method for creating PySimpleGUI tables. - """Internal class used to automatically sync selectors with field changes""" + :returns: a list column widths for use with th PySimpleGUI Table col_widths + parameter + """ + return list(self._width_map) - def __init__(self, frm_reference: Form): - self.frm = frm_reference - self.last_event_widget = None - self.last_event_time = None - self.delay_seconds = 0.5 + def update_headings( + self, element: sg.Table, sort_column=None, sort_order: int = None + ) -> None: + """ + Perform the actual update to the PySimpleGUI Table heading. + Note: Not typically called by the end user. - def __call__(self, event): - # keep track of time on same widget - if event.widget == self.last_event_widget: - self.last_event_time = time() - self.last_event_widget = event.widget + :param element: The PySimpleGUI Table element + :param sort_column: The column to show the sort direction indicators on + :param sort_order: A SORT_* constant (SORT_NONE, SORT_ASC, SORT_DESC) + :returns: None + """ - # get widget type - widget_type = event.widget.__class__.__name__ + # Load in our marker characters. We will use them to both display the + # sort direction and to detect current direction + try: + asc = themepack.marker_sort_asc + except AttributeError: + asc = "\u25BC" + try: + desc = themepack.marker_sort_desc + except AttributeError: + desc = "\u25B2" - # immediately sync combo/checkboxs - if widget_type in ["Combobox", "Checkbutton"]: - self.sync(event.widget, widget_type) + for i, x in zip(range(len(self)), self): + # Clear the direction markers + x["heading"] = x["heading"].replace(asc, "").replace(desc, "") + if ( + x["column"] == sort_column + and sort_column is not None + and sort_order != SORT_NONE + ): + x["heading"] += asc if sort_order == SORT_ASC else desc + element.Widget.heading(i, text=x["heading"], anchor="w") - # use tk.after() for text, so waits for pause in typing to update selector. - if widget_type in ["Entry", "Text"]: - self.frm.window.TKroot.after( - int(self.delay_seconds * 1000), - lambda: self.delay(event.widget, widget_type), - ) + def enable_heading_function(self, element: sg.Table, fn: callable) -> None: + """ + Enable the sorting callbacks for each column index, or saving by click the + unsaved changes column + Note: Not typically used by the end user. Called from `Form.auto_map_elements()` - def sync(self, widget, widget_type): - for e in self.frm.element_map: - if e["element"].widget == widget: - data_key = e["table"] - column = e["column"] - element = e["element"] - new_value = element.get() + :param element: The PySimpleGUI Table element associated with this TableHeading + :param fn: A callback functions to run when a heading is clicked. The callback + should take one column parameter. + :returns: None + """ + if self.sort_enable: + for i in range(len(self)): + if self[i]["column"] is not None: + element.widget.heading( + i, command=functools.partial(fn, self[i]["column"], False) + ) + self.update_headings(element) + if self.save_enable: + element.widget.heading(0, command=functools.partial(fn, None, save=True)) - dataset = self.frm[data_key] + def insert(self, idx, heading_column: str, column: str = None, *args, **kwargs): + super().insert(idx, {"heading": heading_column, "column": column}) - # set the value to the parent pk - if widget_type == TK_COMBOBOX: - combobox_values = dataset.combobox_values(column) - new_value = combobox_values[widget.current()].get_pk() - # get cast new value to correct type - for col in dataset.column_info: - if col["name"] == column: - new_value = col.cast(new_value) - break +class _HeadingCallback: - # see if there was a change - old_value = dataset.get_current_row()[column] - new_value = dataset.value_changed( - column, old_value, new_value, bool(widget_type == TK_CHECKBUTTON) - ) - if new_value is not Boolean.FALSE: - # push row to dataset and update - dataset.set_current(column, new_value) + """Internal class used when sg.Table column headings are clicked.""" - # Update tableview if uses column: - if dataset.column_likely_in_selector(column): - self.frm.update_selectors(dataset.key) + def __init__(self, frm_reference: Form, data_key: str): + """ + Create a new _HeadingCallback object. - def delay(self, widget, widget_type): - if self.last_event_time: - elapsed_sec = time() - self.last_event_time - if elapsed_sec >= self.delay_seconds: - self.sync(widget, widget_type) + :param frm_reference: `Form` object + :param data_key: `DataSet` key + :returns: None + """ + self.frm: Form = frm_reference + self.data_key = data_key + + def __call__(self, column, save): + if save: + self.frm[self.data_key].save_record() + # force a timeout, without this + # info popup creation broke pysimplegui events, weird! + self.frm.window.read(timeout=1) else: - self.sync(widget, widget_type) + self.frm[self.data_key].sort_cycle( + column, self.data_key, update_elements=True + ) # ====================================================================================== @@ -6125,9 +7162,12 @@ class ThemePack: # fmt: on # Markers # ---------------------------------------- - "marker_unsaved": "\u2731", - "marker_required": "\u2731", + "unsaved_column_header": "💾", + "unsaved_column_width": 3, + "marker_unsaved": "✱", + "marker_required": "✱", "marker_required_color": "red2", + "placeholder_color": "grey", # Sorting icons # ---------------------------------------- "marker_sort_asc": "\u25BC", @@ -6141,7 +7181,7 @@ class ThemePack: # Label Size # Sets the default label (text) size when `field()` is used. # A label is static text that is displayed near the element to describe it. - "default_label_size": (20, 1), # (width, height) + "default_label_size": (15, 1), # (width, height) # Element Size # Sets the default element size when `field()` is used. # The size= parameter of `field()` will override this. @@ -6156,6 +7196,11 @@ class ThemePack: "text_min_width": 80, "combobox_min_width": 80, "checkbox_min_width": 75, + "datepicker_min_width": 80, + # Display boolean columns as checkboxes in sg.Tables + "display_boolean_as_checkbox": True, + "checkbox_true": "☑", + "checkbox_false": "☐", } """ Default Themepack. @@ -6250,6 +7295,11 @@ class LanguagePack: # ------------------------------------------------------------------------------ # Text, Varchar, Char, Null Default, used exclusively for description_column "description_column_str_null_default": "New Record", + # Placeholder automatically added to Input/Multiline + # that represent Not-Null fields. + "notnull_placeholder": "*Required", + "search_placeholder": "🔍 Search...", + "combo_placeholder": "Please select one:", # Prepended to parent description_column "duplicate_prepend": "Copy of ", # ------------------------------------------------------------------------------ @@ -6305,6 +7355,9 @@ class LanguagePack: "dataset_save_keyed_fail": "Query failed: {exception}.", "dataset_save_fail_title": "Problem Saving", "dataset_save_fail": "Query failed: {exception}.", + # DataSet search + "dataset_search_failed_title": "Search Failed", + "dataset_search_failed": "Failed to find:\n{search_string}", # ------------------------------------------------------------------------------ # Delete # ------------------------------------------------------------------------------ @@ -6335,7 +7388,7 @@ class LanguagePack: # ------------------------------------------------------------------------------ "quick_edit_title": "Quick Edit - {data_key}", # ------------------------------------------------------------------------------ - # Error when importing module for driver + # For Error when importing module for driver # ------------------------------------------------------------------------------ "import_module_failed_title": "Problem importing module", "import_module_failed": "Unable to import module neccessary for {name}\nException: {exception}\n\nTry `pip install {requires}`", # fmt: skip # noqa: E501 @@ -6372,6 +7425,19 @@ def __call__(self, lp_dict={}): lang = LanguagePack() +class LangFormat(dict): + + """ + This is a convenience class used by LanguagePack format_map calls, allowing users to + not include expected variables. + + Note: This is typically not used by the end user. + """ + + def __missing__(self, key): + return None + + # ====================================================================================== # ABSTRACTION LAYERS # ====================================================================================== @@ -6425,6 +7491,7 @@ def __init__( default: None, pk: bool, virtual: bool = False, + generated: bool = False, ): self._column = { "name": name, @@ -6433,6 +7500,7 @@ def __init__( "default": default, "pk": pk, "virtual": virtual, + "generated": generated, } def __str__(self): @@ -6482,7 +7550,14 @@ def cast(self, value: any) -> any: # Integer type casting elif domain in ["INT", "INTEGER", "BOOLEAN"]: try: - value = int(value) + if isinstance(value, int): + pass + elif isinstance(value, ElementRow): + value = int(value) + elif type(value) is str: + value = float(value) + if value == int(value): + value = int(value) except (ValueError, TypeError): value = str(value) @@ -6496,8 +7571,10 @@ def cast(self, value: any) -> any: # Date casting elif domain == "DATE": try: - value = datetime.strptime(value, "%Y-%m-%d").date() - # TODO: ValueError for sqlserver returns date(): 2023-04-27 15:31:13.170000 + if not isinstance(value, dt.date): + value = dt.datetime.strptime(value, "%Y-%m-%d").date() + # TODO: ValueError for sqlserver returns: + # date(): 2023-04-27 15:31:13.170000 except (TypeError, ValueError) as e: logger.debug( f"Unable to cast {value} to a datetime.date object. " @@ -6512,7 +7589,7 @@ def cast(self, value: any) -> any: parsed = False for timestamp_format in timestamp_formats: try: - value = datetime.strptime(value, timestamp_format) + value = dt.datetime.strptime(value, timestamp_format) # value = dt.datetime() parsed = True break @@ -6526,12 +7603,10 @@ def cast(self, value: any) -> any: value = str(value) # other date/time casting - elif domain in [ - "TIME", - "DATETIME", - ]: # TODO: i'm sure there is a lot of work to do here + # TODO: i'm sure there is a lot of work to do here + elif domain in ["TIME", "DATETIME"]: try: - value = datetime.date(value) + value = dt.date(value) except TypeError: print( "Unable to case datetime/time/timestamp. Casting to string instead." @@ -6593,10 +7668,10 @@ def __init__(self, driver: SQLDriver, table: str): "FLOAT": 0.0, "DECIMAL": 0.0, "BOOLEAN": 0, - "TIME": lambda x: datetime.now().strftime("%H:%M:%S"), - "DATE": lambda x: date.today().strftime("%Y-%m-%d"), - "TIMESTAMP": lambda x: datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "DATETIME": lambda x: datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "TIME": lambda x: dt.datetime.now().strftime("%H:%M:%S"), + "DATE": lambda x: dt.date.today().strftime("%Y-%m-%d"), + "TIMESTAMP": lambda x: dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "DATETIME": lambda x: dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } super().__init__() @@ -6683,6 +7758,13 @@ def default_row_dict(self, dataset: DataSet) -> dict: # Perhaps our default dict does not yet support this datatype null_default = None + # return PK_PLACEHOLDER if this is a fk_relationship. + # trick used in Combo for the pk to display placeholder + rels = Relationship.get_relationships(dataset.table) + rel = next((r for r in rels if r.fk_column == c.name), None) + if rel: + null_default = PK_PLACEHOLDER + # skip primary keys if not c.pk: # put default in description_column @@ -6702,7 +7784,8 @@ def default_row_dict(self, dataset: DataSet) -> dict: else: default = null_default # string-like, not description_column - default = "" + else: + default = "" else: # Load the default that was fetched from the database # during ColumnInfo creation @@ -6768,10 +7851,11 @@ def get_virtual_names(self) -> List[str]: def _contains_key_value_pair(self, key, value): # used by __contains__ return any(key in d and d[key] == value for d in self) + # TODO: check if something looks like a statement for complex defaults? Regex? @staticmethod def _looks_like_function( s: str, - ): # TODO: check if something looks like a statement for complex defaults? Regex? + ): # check if the string is empty if not s: return False @@ -6996,7 +8080,7 @@ def relationships(self): # based on specifics of the database # --------------------------------------------------------------------- # This is a generic way to estimate the next primary key to be generated. - # Note that this is not always a reliable way, as manual inserts which assign a + # This is not always a reliable way, as manual inserts which assign a # primary key value don't always update the sequencer for the given database. This # is just a default way to "get things working", but the best bet is to override # this in the derived class and get the value right from the sequencer. @@ -7120,7 +8204,7 @@ def generate_where_clause(dataset: DataSet) -> str: # Children without cascade-filtering parent aren't displayed if not parent_pk: - parent_pk = "NULL" + parent_pk = PK_PLACEHOLDER clause = f" WHERE {table}.{r.fk_column}={str(parent_pk)}" if where: @@ -7160,9 +8244,7 @@ def generate_query( f' {dataset.order_clause if order_clause else ""}' ) - def delete_record( - self, dataset: DataSet, cascade=True - ): # TODO: get ON DELETE CASCADE from db + def delete_record(self, dataset: DataSet, cascade=True): # Get data for query table = self.quote_table(dataset.table) pk_column = self.quote_column(dataset.pk_column) @@ -7543,7 +8625,7 @@ def get_tables(self): def column_info(self, table): # Return a list of column names - q = f"PRAGMA table_info({self.quote_table(table)})" + q = f"PRAGMA table_xinfo({self.quote_table(table)})" rows = self.execute(q, silent=True) names = [] col_info = ColumnInfo(self, table) @@ -7555,9 +8637,15 @@ def column_info(self, table): notnull = row["notnull"] default = row["dflt_value"] pk = row["pk"] + generated = row["hidden"] col_info.append( Column( - name=name, domain=domain, notnull=notnull, default=default, pk=pk + name=name, + domain=domain, + notnull=notnull, + default=default, + pk=pk, + generated=generated, ) ) @@ -8586,16 +9674,16 @@ def execute( timestamp_format = "%Y-%m-%dT%H:%M:%S.%f" else: timestamp_format = "%Y-%m-%dT%H:%M:%S" - dt_value = datetime.strptime(timestamp_str, timestamp_format) + dt_value = dt.datetime.strptime(timestamp_str, timestamp_format) value = dt_value.strftime("%Y-%m-%d") elif isinstance(value, jpype.JPackage("java").sql.Date): date_str = value.toString() date_format = "%Y-%m-%d" - value = datetime.strptime(date_str, date_format).date() + value = dt.datetime.strptime(date_str, date_format).date() elif isinstance(value, jpype.JPackage("java").sql.Time): time_str = value.toString() time_format = "%H:%M:%S" - value = datetime.strptime(time_str, time_format).time() + value = dt.datetime.strptime(time_str, time_format).time() elif value is not None: value = value # TODO: More conversions?