Files
KPK/numberpad.py
2026-06-23 15:20:56 +02:00

511 lines
15 KiB
Python

# Verze upravená JQ:
# - reaguje na klávesnici
# - obsahuje ochranu proti zdvojování písmen
# - umožňuje vstup ze čtečky, případně diakritiku přemění na čísla
from kivy.app import App
from kivy.uix.screenmanager import ScreenManager, Screen
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.metrics import dp
from kivy.logger import Logger
from kivy.uix.widget import Widget
# =====================================================
# NUMBER PAD
# =====================================================
from kivy.metrics import dp
from kivy.uix.modalview import ModalView
from kivy.uix.boxlayout import BoxLayout
from time import time
# JQ
from kivy.core.window import Window
from kivy.clock import Clock
# JQ
class NumberPad(ModalView):
def _btn(self, txt):
btn = Button(text=txt)
if txt == "⌫":
btn.font_size = 36
btn.bind(on_press=lambda *_: self._press(txt))
return btn
def __init__(
self,
*,
mode="number",
max_len=10,
mask=False,
allow_fraction=False,
show_dot=True,
decimal_places=2,
initial_value="",
overwrite_on_first=False,
on_accept=None,
on_cancel=None,
when=None, #obsahuje time promenou pro potlaceni eventu
allow_text=False, # 🔥 NOVÉ
auto_accept_scanner=True, # 🔥 NOVÉ
scanner_timeout=0.05, # 🔥 NOVÉ
**kwargs,
):
super().__init__(
size_hint=(None, None),
size=(dp(360), dp(480)),
auto_dismiss=False,
**kwargs,
)
self.when = when
self.overwrite_on_first = overwrite_on_first
self._editing_started = False
self.on_accept = on_accept
self.on_cancel = on_cancel
self.show_dot = show_dot
# JQ
self._cz_to_num = str.maketrans("+ěščřžýáíé", "1234567890")
self.allow_text = allow_text
self.auto_accept_scanner = auto_accept_scanner
self.scanner_timeout = scanner_timeout
self._last_text = None
self._last_text_time = 0
# JQ
self._last_key_time = 0
self._scanner_buffer = ""
# --- interní stav (ZDROJ PRAVDY) ---
self.num = ""
self.den = "1"
self.is_fraction = False
self.mode = mode
self.allow_fraction = allow_fraction
self.decimal_places = decimal_places
self.max_len = max_len
self.mask = mask
# ---------- ROOT (JEDNOU!) ----------
root = BoxLayout(
orientation="vertical",
padding=dp(10),
spacing=dp(10),
)
self.add_widget(root)
# --- display ---
self.lbl = Label(
font_size=32,
size_hint_y=None,
height=dp(60),
halign="right",
valign="middle",
)
self.lbl.bind(size=lambda *_: setattr(self.lbl, "text_size", self.lbl.size))
root.add_widget(self.lbl)
# --- keypad ---
grid = GridLayout(cols=3, spacing=dp(5), size_hint_y=1)
for txt in ["7", "8", "9", "4", "5", "6", "1", "2", "3"]:
grid.add_widget(self._btn(txt))
if self.show_dot:
grid.add_widget(self._btn("."))
else:
grid.add_widget(Label())
grid.add_widget(self._btn("0"))
grid.add_widget(self._btn("⌫"))
root.add_widget(grid)
# --- fractions ---
if self.allow_fraction:
frac = BoxLayout(size_hint_y=None, height=dp(50), spacing=dp(10))
frac.add_widget(self._btn("1/1"))
frac.add_widget(self._btn("1/2"))
frac.add_widget(self._btn("1/3"))
root.add_widget(frac)
# --- actions ---
actions = BoxLayout(size_hint_y=None, height=dp(60), spacing=dp(10))
btn_cancel = Button(text="ZRUŠIT")
btn_ok = Button(text="OK")
btn_cancel.bind(on_release=self._cancel)
btn_ok.bind(on_release=self._accept)
actions.add_widget(btn_cancel)
actions.add_widget(btn_ok)
root.add_widget(actions)
# ---------- TADY JE KLÍČ ----------
self._set_initial_value(initial_value)
self._overwrite = bool(initial_value)
self._refresh()
def _set_initial_value(self, val: str):
"""
Nastaví počáteční hodnotu NumberPadu z řetězce:
"3" nebo "3/2"
"""
if not val:
return
val = val.strip()
if "/" in val and self.allow_fraction:
try:
a, b = val.split("/", 1)
self.num = a
self.den = b
self.is_fraction = (b != "1")
except Exception:
self.num = ""
self.den = "1"
self.is_fraction = False
else:
# čisté číslo
self.num = val
self.den = "1"
self.is_fraction = False
def disable(self):
for w in self.children[0].children:
if hasattr(w, "disabled"):
w.disabled = True
# -------------------------------------------------
def clear(self):
self.num = ""
self.den = "1"
self.value = ""
self._refresh()
def reset(self):
"""
Resetuje NumberPad do počátečního stavu
"""
self.num = ""
self.den = "1"
self.is_fraction = False
self.value = ""
self._refresh()
def _press(self, txt):
print(txt)
def _start_new_input(self):
self.num = ""
self.den = "1"
self._editing_started = True
self.is_fraction = False
self._overwrite = False
# code: žádná fraction, žádná tečka
if self.mode == "code":
if txt in ("1/1", "1/2", "1/3"):
return
# fraction: žádná desetinná tečka
if self.allow_fraction and txt == ".":
return
# BACKSPACE
if txt == "⌫":
self._scanner_buffer = "" # 🔥 FIX
if self.num:
# mažu poslední cifru čitatele
self.num = self.num[:-1]
else:
# čitatel prázdný → ruším fraction (zpět na /1)
if self.allow_fraction and self.den != "1":
self.den = "1"
self._refresh()
return
# FRACTION
if txt in ("1/1", "1/2", "1/3"):
if not self.allow_fraction:
return
new_den = txt.split("/")[1] # "1", "2", "3"
# toggle chování
if self.den == new_den:
self.den = "1"
else:
self.den = new_den
self._refresh()
return
# DESETINNÁ TEČKA (jen decimal režim)
if txt == ".":
if not self.show_dot or self.allow_fraction:
return
if "." in self.num: return
if self._overwrite:
self._start_new_input()
self.num = self.num + "." if self.num else "0."
self._refresh()
return
# ČÍSLICE
# ---- CODE REŽIM ----
if self.mode == "code":
if len(self.num) < self.max_len:
self.num += txt
self._refresh()
return
# ---- DECIMAL REŽIM (bez fraction) ----
if not self.allow_fraction:
# simulace budoucí hodnoty
if self._overwrite:
self._start_new_input()
new_num = self.num + txt if self.num else txt # kontrola desetinných míst
if "." in new_num:
_, dec_part = new_num.split(".", 1)
if len(dec_part) > self.decimal_places:
return
# kontrola délky celé části
if "." not in self.num and len(self.num) >= self.max_len:
return
self.num = new_num
self._refresh()
return
# ---- FRACTION REŽIM ----
if self.allow_fraction:
if self._overwrite:
# první stisk → přepíšeme čitatele, jmenovatel zachováme
self.num = txt
self._overwrite = False
else:
if len(self.num) < self.max_len:
self.num += txt
self._refresh()
return
def _start_new_input(self):
"""
Připraví NumberPad na nový vstup přepíše initial value
"""
self.num = ""
self.den = "1"
self.is_fraction = False
self._overwrite = False
def _refresh(self):
# JQ
if self.allow_text:
self.value = self.num
self.lbl.text = self.num
return
# JQ
n = self.num if self.num else "0"
# --- výstupní hodnota (datový kontrakt) ---
if self.allow_fraction:
self.value = f"{n}/{self.den}"
else:
self.value = n
# --- ZOBRAZENÍ ---
if self.mode == "code":
# 🔑 KLÍČ: při prázdném vstupu nezobrazuj NIC
if not self.num:
self.lbl.text = ""
else:
self.lbl.text = "*" * len(self.num)
return
# --- ostatní režimy ---
if not self.allow_fraction:
self.lbl.text = n
return
if self.den == "1":
self.lbl.text = n
else:
self.lbl.text = f"{n}/{self.den}"
def _compose(self) -> str:
n = self.num if self.num else "0"
if not self.allow_fraction:
# čistý numerický výstup
return n
return f"{n}/{self.den}"
def _accept(self, *_):
if not self.num:
Logger.info("IGNORE empty accept")
return
Logger.info(f"NumberPad ACCEPT: {self.value}")
self._scanner_buffer = ""
# validace fraction
if self.when:
self.when = time()
if self.allow_fraction:
try:
num, den = self.value.split("/")
if int(num) == 0:
# 0 kusů → ignorovat
self.dismiss()
return
except Exception:
pass
if self.on_accept:
self.on_accept(self.value)
self.dismiss()
def _cancel(self, *_):
Logger.info("NumberPad CANCEL")
if self.on_cancel:
self.on_cancel()
self.dismiss()
def enable(self):
for w in self.children[0].children:
if hasattr(w, "disabled"):
w.disabled = False
# JQ
def on_open(self):
Logger.info("NumberPad opened → bind keyboard")
Window.bind(on_key_down=self._on_key_down)
Window.bind(on_textinput=self._on_textinput)
def on_dismiss(self):
Window.unbind(on_key_down=self._on_key_down)
Window.unbind(on_textinput=self._on_textinput)
def _keyboard_closed(self):
Logger.info("Keyboard closed")
self._keyboard = None
def _on_key_down(self, *args):
# ------------------------
# NORMALIZACE ARGUMENTŮ
# ------------------------
if len(args) == 4:
# Keyboard API
keyboard, keycode, text, modifiers = args
key = keycode[1] if isinstance(keycode, tuple) else keycode
elif len(args) == 5:
# Window API
window, key, scancode, text, modifiers = args
else:
return False
now = time()
# delta = now - self._last_key_time
self._last_key_time = now
# ------------------------
# CONTROL KEYS
# ------------------------
# 🔥 ENTER = scanner done
# Tohle fungovalo samostatně, ale ne z volani z loginscreen
# if key in ("enter", "numpadenter", "tab", 13, 271, 9):
# if self.auto_accept_scanner and self.allow_text:
# self._accept()
# else:
# self._accept()
# return True
if key in ("enter", "numpadenter", "tab", 13, 271, 9):
if not self.num:
return True
self._accept()
return True
if key in ("escape", 27):
self._cancel()
return True
if key in ("backspace", 8):
if self.num:
self.num = self.num[:-1]
self._refresh()
return True
return False
def _on_textinput(self, window, text):
Logger.info(f"TEXT INPUT: {text}")
# 🔥 převod CZ → čísla
if text:
text = text.translate(self._cz_to_num)
# anti-duplicate (ponecháme)
now = time()
if text == self._last_text and (now - self._last_text_time) < 0.02:
return True
self._last_text = text
self._last_text_time = now
if self.mode == "code":
for ch in text:
if ch in "0123456789*.":
self._press(ch)
return True
if self.allow_text:
allowed = set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ%&?<>#*[]-_/.")
if text not in allowed:
return True
# 🔥 respektuj max_len
if self.max_len and len(self.num) >= self.max_len:
return True
self.num += text
self._refresh()
return True
for ch in text:
if ch in "0123456789":
self._press(ch)
elif ch in ",." and self.show_dot and not self.allow_fraction:
self._press(".")
return True
return False
# JQ
# =====================================================
# TEST APP
# =====================================================
class TestApp(App):
def build(self):
return Widget() # 👈 prázdný root
def on_start(self):
def done(val):
Logger.info(f"RESULT = {val}")
# pad = NumberPad(
# mode="code",
# max_len=15,
# mask=True,
# allow_fraction=False,
# on_accept=done,
# on_cancel=lambda *_: Logger.info("CANCEL"),
# allow_text=True,
# auto_accept_scanner=True,
# )
pad = NumberPad(
mode="code",
max_len=15,
# on_accept=self._accept,
# on_cancel=self._cancel,
allow_text=True,
auto_accept_scanner=True,
)
pad.open()
if __name__ == "__main__":
TestApp().run()