+
+Write data-driven desktop apps fast! Lightweight Python library supports SQLite, MySQL/MariaDB, PostgreSQL, Flatfile CSV, SQL Server and MS Access. Uses PySimpleGUI layouts.
+
+---
+
+
+
+**[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("