511 lines
15 KiB
Python
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()
|