violit 0.0.2__py3-none-any.whl → 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
violit/app.py CHANGED
@@ -271,8 +271,16 @@ class App(
271
271
  def state(self, default_value, key=None) -> State:
272
272
  """Create a reactive state variable"""
273
273
  if key is None:
274
- name = f"state_{self.state_count}"
275
- self.state_count += 1
274
+ # Streamlit-style: Generate stable key from caller's location
275
+ frame = inspect.currentframe()
276
+ try:
277
+ caller_frame = frame.f_back
278
+ filename = os.path.basename(caller_frame.f_code.co_filename)
279
+ lineno = caller_frame.f_lineno
280
+ # Create stable key: filename_linenumber
281
+ name = f"state_{filename}_{lineno}"
282
+ finally:
283
+ del frame # Avoid reference cycles
276
284
  else:
277
285
  name = key
278
286
  return State(name, default_value)
@@ -861,6 +869,7 @@ class App(
861
869
  t = store['theme']
862
870
 
863
871
  sidebar_style = "" if (sidebar_c or self.static_sidebar_order) else "display: none;"
872
+ main_class = "" if (sidebar_c or self.static_sidebar_order) else "sidebar-collapsed"
864
873
 
865
874
  # Generate CSRF token
866
875
  # Get sid from context (set by middleware) instead of cookies (not set yet on first visit)
@@ -880,7 +889,7 @@ class App(
880
889
  # Debug flag injection
881
890
  debug_script = f'<script>window._debug_mode = {str(self.debug_mode).lower()};</script>'
882
891
 
883
- html = HTML_TEMPLATE.replace("%CONTENT%", main_c).replace("%SIDEBAR_CONTENT%", sidebar_c).replace("%SIDEBAR_STYLE%", sidebar_style).replace("%MODE%", self.mode).replace("%TITLE%", self.app_title).replace("%THEME_CLASS%", t.theme_class).replace("%CSS_VARS%", t.to_css_vars()).replace("%SPLASH%", self._splash_html if self.show_splash else "").replace("%CONTAINER_MAX_WIDTH%", self.container_max_width).replace("%CSRF_SCRIPT%", csrf_script).replace("%DEBUG_SCRIPT%", debug_script)
892
+ html = HTML_TEMPLATE.replace("%CONTENT%", main_c).replace("%SIDEBAR_CONTENT%", sidebar_c).replace("%SIDEBAR_STYLE%", sidebar_style).replace("%MAIN_CLASS%", main_class).replace("%MODE%", self.mode).replace("%TITLE%", self.app_title).replace("%THEME_CLASS%", t.theme_class).replace("%CSS_VARS%", t.to_css_vars()).replace("%SPLASH%", self._splash_html if self.show_splash else "").replace("%CONTAINER_MAX_WIDTH%", self.container_max_width).replace("%CSRF_SCRIPT%", csrf_script).replace("%DEBUG_SCRIPT%", debug_script)
884
893
  return HTMLResponse(html)
885
894
 
886
895
  @self.fastapi.post("/action/{cid}")
@@ -1036,9 +1045,12 @@ class App(
1036
1045
 
1037
1046
  self.debug_print(f" Action found: {act is not None}")
1038
1047
 
1048
+ # Detect if this is a navigation action (nav menu click)
1049
+ is_navigation = cid.startswith('nav_menu')
1050
+
1039
1051
  if act:
1040
1052
  store['eval_queue'] = []
1041
- self.debug_print(f" Executing action for CID: {cid}...")
1053
+ self.debug_print(f" Executing action for CID: {cid} (navigation={is_navigation})...")
1042
1054
  act(v) if v is not None else act()
1043
1055
  self.debug_print(f" Action executed")
1044
1056
 
@@ -1050,9 +1062,10 @@ class App(
1050
1062
  self.debug_print(f" Dirty components: {len(dirty)} ({[c.id for c in dirty]})")
1051
1063
 
1052
1064
  # Send all dirty components via WebSocket
1065
+ # Pass is_navigation flag to enable/disable smooth transitions
1053
1066
  if dirty:
1054
- self.debug_print(f" Sending {len(dirty)} updates via WebSocket...")
1055
- await self.ws_engine.push_updates(sid, dirty)
1067
+ self.debug_print(f" Sending {len(dirty)} updates via WebSocket (navigation={is_navigation})...")
1068
+ await self.ws_engine.push_updates(sid, dirty, is_navigation=is_navigation)
1056
1069
  self.debug_print(f" [OK] Updates sent successfully")
1057
1070
  else:
1058
1071
  self.debug_print(f" [!] No dirty components found - nothing to update")
@@ -1398,6 +1411,7 @@ HTML_TEMPLATE = """
1398
1411
  <style>
1399
1412
  :root {
1400
1413
  %CSS_VARS%
1414
+ --sidebar-width: 300px;
1401
1415
  }
1402
1416
  sl-alert { --sl-color-primary-500: var(--sl-primary); --sl-color-primary-600: var(--sl-primary); }
1403
1417
  sl-alert::part(base) { border: 1px solid var(--sl-border); }
@@ -1417,31 +1431,39 @@ HTML_TEMPLATE = """
1417
1431
 
1418
1432
  #root { display: flex; width: 100%; min-height: 100vh; }
1419
1433
  #sidebar {
1420
- width: 300px;
1434
+ position: fixed;
1435
+ top: 0;
1436
+ left: 0;
1437
+ width: var(--sidebar-width);
1438
+ height: 100vh;
1421
1439
  background: var(--sl-bg-card);
1422
1440
  border-right: 1px solid var(--sl-border);
1423
1441
  padding: 2rem 1rem;
1424
1442
  display: flex;
1425
1443
  flex-direction: column;
1426
1444
  gap: 1rem;
1427
- flex-shrink: 0;
1428
1445
  overflow-y: auto;
1429
1446
  overflow-x: hidden;
1430
- white-space: nowrap;
1431
- position: sticky;
1432
- top: 0;
1433
- height: 100vh;
1447
+ white-space: nowrap;
1448
+ z-index: 1100;
1434
1449
  }
1435
1450
  #sidebar.collapsed { width: 0; padding: 2rem 0; border-right: none; opacity: 0; }
1436
1451
 
1437
1452
  #main {
1438
1453
  flex: 1;
1454
+ margin-left: var(--sidebar-width);
1439
1455
  display: flex;
1440
1456
  flex-direction: column;
1441
1457
  align-items: center;
1442
- padding: 0 1.5rem 3rem 1.5rem;
1443
- transition: padding 0.3s ease;
1458
+ padding: 0 1.5rem 3rem 2.5rem;
1459
+ transition: margin-left 0.3s ease, padding 0.3s ease;
1444
1460
  }
1461
+ #main.sidebar-collapsed { margin-left: 0; }
1462
+ /* Chat input container positioning - respects sidebar */
1463
+ .chat-input-container { left: var(--sidebar-width) !important; transition: left 0.3s ease; }
1464
+ #sidebar.collapsed ~ #main .chat-input-container,
1465
+ #main.sidebar-collapsed .chat-input-container { left: 0 !important; }
1466
+
1445
1467
  #header { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; padding: 1rem 0; display: flex; align-items: center; }
1446
1468
  #app { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; display: flex; flex-direction: column; gap: 1.5rem; }
1447
1469
 
@@ -1658,116 +1680,87 @@ HTML_TEMPLATE = """
1658
1680
  debugLog("[WebSocket] Message received");
1659
1681
  const msg = JSON.parse(e.data);
1660
1682
  if(msg.type === 'update') {
1661
- // Separate page transitions from regular updates
1662
- const pageUpdates = [];
1663
- const regularUpdates = [];
1664
-
1665
- msg.payload.forEach(item => {
1666
- // Only page_renderer gets smooth transition
1667
- if (item.id.startsWith('page_renderer')) {
1668
- pageUpdates.push(item);
1669
- } else {
1670
- regularUpdates.push(item);
1671
- }
1672
- });
1683
+ // Check if this is a navigation update (page transition)
1684
+ // Server sends isNavigation flag based on action type
1685
+ const isNavigation = msg.isNavigation === true;
1673
1686
 
1674
- // Regular updates: apply immediately without animation
1675
- regularUpdates.forEach(item => {
1676
- const el = document.getElementById(item.id);
1677
-
1678
- // Focus Guard: Skip update if element is focused input to prevent interrupting typing
1679
- if (document.activeElement && el) {
1680
- const isSelfOrChild = document.activeElement.id === item.id || el.contains(document.activeElement);
1681
- const isShadowChild = document.activeElement.closest && document.activeElement.closest(`#${item.id}`);
1682
-
1683
- if (isSelfOrChild || isShadowChild) {
1684
- // Check if it's actually an input that needs protection
1685
- const tag = document.activeElement.tagName.toLowerCase();
1686
- const isInput = tag === 'input' || tag === 'textarea' || tag.startsWith('sl-input') || tag.startsWith('sl-textarea');
1687
+ // Helper function to apply updates
1688
+ const applyUpdates = (items) => {
1689
+ items.forEach(item => {
1690
+ const el = document.getElementById(item.id);
1691
+
1692
+ // Focus Guard: Skip update if element is focused input to prevent interrupting typing
1693
+ if (document.activeElement && el) {
1694
+ const isSelfOrChild = document.activeElement.id === item.id || el.contains(document.activeElement);
1695
+ const isShadowChild = document.activeElement.closest && document.activeElement.closest(`#${item.id}`);
1687
1696
 
1688
- // If it's an input, block update. If it's a button (nav menu), ALLOW update.
1689
- if (isInput) {
1690
- return;
1697
+ if (isSelfOrChild || isShadowChild) {
1698
+ // Check if it's actually an input that needs protection
1699
+ const tag = document.activeElement.tagName.toLowerCase();
1700
+ const isInput = tag === 'input' || tag === 'textarea' || tag.startsWith('sl-input') || tag.startsWith('sl-textarea');
1701
+
1702
+ // If it's an input, block update. If it's a button (nav menu), ALLOW update.
1703
+ if (isInput) {
1704
+ return;
1705
+ }
1691
1706
  }
1692
- }
1693
- }
1707
+ }
1694
1708
 
1695
- if(el) {
1696
- // Smart update for specific widget types to preserve animations/instances
1697
- const widgetType = item.id.split('_')[0];
1698
- let smartUpdated = false;
1699
-
1700
- // Checkbox/Toggle: Update checked property only (preserve animation)
1701
- if (widgetType === 'checkbox' || widgetType === 'toggle') {
1702
- // Parse new HTML to extract checked state
1703
- const temp = document.createElement('div');
1704
- temp.innerHTML = item.html;
1705
- const newCheckbox = temp.querySelector('sl-checkbox, sl-switch');
1709
+ if(el) {
1710
+ // Smart update for specific widget types to preserve animations/instances
1711
+ const widgetType = item.id.split('_')[0];
1712
+ let smartUpdated = false;
1706
1713
 
1707
- if (newCheckbox) {
1708
- // Find the actual checkbox element (may be direct or nested)
1709
- const checkboxEl = el.tagName && (el.tagName.toLowerCase() === 'sl-checkbox' || el.tagName.toLowerCase() === 'sl-switch')
1710
- ? el
1711
- : el.querySelector('sl-checkbox, sl-switch');
1714
+ // Checkbox/Toggle: Update checked property only (preserve animation)
1715
+ if (widgetType === 'checkbox' || widgetType === 'toggle') {
1716
+ // Parse new HTML to extract checked state
1717
+ const temp = document.createElement('div');
1718
+ temp.innerHTML = item.html;
1719
+ const newCheckbox = temp.querySelector('sl-checkbox, sl-switch');
1712
1720
 
1713
- if (checkboxEl) {
1714
- const shouldBeChecked = newCheckbox.hasAttribute('checked');
1715
- // Only update if different to avoid interrupting user interaction
1716
- if (checkboxEl.checked !== shouldBeChecked) {
1717
- checkboxEl.checked = shouldBeChecked;
1721
+ if (newCheckbox) {
1722
+ // Find the actual checkbox element (may be direct or nested)
1723
+ const checkboxEl = el.tagName && (el.tagName.toLowerCase() === 'sl-checkbox' || el.tagName.toLowerCase() === 'sl-switch')
1724
+ ? el
1725
+ : el.querySelector('sl-checkbox, sl-switch');
1726
+
1727
+ if (checkboxEl) {
1728
+ const shouldBeChecked = newCheckbox.hasAttribute('checked');
1729
+ // Only update if different to avoid interrupting user interaction
1730
+ if (checkboxEl.checked !== shouldBeChecked) {
1731
+ checkboxEl.checked = shouldBeChecked;
1732
+ }
1733
+ smartUpdated = true;
1718
1734
  }
1719
- smartUpdated = true;
1720
1735
  }
1721
1736
  }
1722
- }
1723
-
1724
- // Data Editor: Update AG Grid data only
1725
- if (widgetType === 'data' && item.id.includes('editor')) {
1726
- // item.id is like "data_editor_xxx_wrapper", extract base cid
1727
- const baseCid = item.id.replace('_wrapper', '');
1728
- const gridApi = window['gridApi_' + baseCid];
1729
- if (gridApi) {
1730
- // Extract rowData from new HTML
1731
- const match = item.html.match(/rowData:\s*(\[.*?\])/s);
1732
- if (match) {
1733
- try {
1734
- const newData = JSON.parse(match[1]);
1735
- gridApi.setRowData(newData);
1736
- smartUpdated = true;
1737
- } catch (e) {
1738
- console.error('Failed to parse AG Grid data:', e);
1737
+
1738
+ // Data Editor: Update AG Grid data only
1739
+ if (widgetType === 'data' && item.id.includes('editor')) {
1740
+ // item.id is like "data_editor_xxx_wrapper", extract base cid
1741
+ const baseCid = item.id.replace('_wrapper', '');
1742
+ const gridApi = window['gridApi_' + baseCid];
1743
+ if (gridApi) {
1744
+ // Extract rowData from new HTML
1745
+ const match = item.html.match(/rowData:\s*(\[.*?\])/s);
1746
+ if (match) {
1747
+ try {
1748
+ const newData = JSON.parse(match[1]);
1749
+ gridApi.setRowData(newData);
1750
+ smartUpdated = true;
1751
+ } catch (e) {
1752
+ console.error('Failed to parse AG Grid data:', e);
1753
+ }
1739
1754
  }
1740
1755
  }
1741
1756
  }
1742
- }
1743
-
1744
- // Default: Full DOM replacement
1745
- if (!smartUpdated) {
1746
- purgePlotly(el);
1747
- el.outerHTML = item.html;
1748
1757
 
1749
- // Execute scripts
1750
- const temp = document.createElement('div');
1751
- temp.innerHTML = item.html;
1752
- temp.querySelectorAll('script').forEach(s => {
1753
- const script = document.createElement('script');
1754
- script.textContent = s.textContent;
1755
- document.body.appendChild(script);
1756
- script.remove();
1757
- });
1758
- }
1759
- }
1760
- });
1761
-
1762
- // Page updates: use View Transitions if available
1763
- if (pageUpdates.length > 0) {
1764
- const updatePages = () => {
1765
- pageUpdates.forEach(item => {
1766
- const el = document.getElementById(item.id);
1767
- if(el) {
1758
+ // Default: Full DOM replacement
1759
+ if (!smartUpdated) {
1768
1760
  purgePlotly(el);
1769
1761
  el.outerHTML = item.html;
1770
1762
 
1763
+ // Execute scripts
1771
1764
  const temp = document.createElement('div');
1772
1765
  temp.innerHTML = item.html;
1773
1766
  temp.querySelectorAll('script').forEach(s => {
@@ -1777,14 +1770,17 @@ HTML_TEMPLATE = """
1777
1770
  script.remove();
1778
1771
  });
1779
1772
  }
1780
- });
1781
- };
1782
-
1783
- if (document.body.classList.contains('anim-soft') && document.startViewTransition) {
1784
- document.startViewTransition(() => updatePages());
1785
- } else {
1786
- updatePages();
1787
- }
1773
+ }
1774
+ });
1775
+ };
1776
+
1777
+ // Apply updates: use View Transitions ONLY for navigation
1778
+ if (isNavigation && document.body.classList.contains('anim-soft') && document.startViewTransition) {
1779
+ // Navigation: smooth page transition
1780
+ document.startViewTransition(() => applyUpdates(msg.payload));
1781
+ } else {
1782
+ // Regular updates: apply immediately without animation for snappy response
1783
+ applyUpdates(msg.payload);
1788
1784
  }
1789
1785
  } else if (msg.type === 'eval') {
1790
1786
  const func = new Function(msg.code);
@@ -1839,8 +1835,16 @@ HTML_TEMPLATE = """
1839
1835
 
1840
1836
  function toggleSidebar() {
1841
1837
  const sb = document.getElementById('sidebar');
1838
+ const main = document.getElementById('main');
1839
+ const chatInput = document.querySelector('.chat-input-container');
1842
1840
  sb.classList.toggle('collapsed');
1841
+ main.classList.toggle('sidebar-collapsed');
1842
+ // Also adjust chat input container if present
1843
+ if (chatInput) {
1844
+ chatInput.style.left = sb.classList.contains('collapsed') ? '0' : '300px';
1845
+ }
1843
1846
  }
1847
+
1844
1848
  function createToast(message, variant = 'primary', icon = 'info-circle') {
1845
1849
  const variantColors = { primary: '#0ea5e9', success: '#10b981', warning: '#f59e0b', danger: '#ef4444' };
1846
1850
  const toast = document.createElement('div');
@@ -1971,7 +1975,7 @@ HTML_TEMPLATE = """
1971
1975
  <div id="sidebar" style="%SIDEBAR_STYLE%">
1972
1976
  %SIDEBAR_CONTENT%
1973
1977
  </div>
1974
- <div id="main">
1978
+ <div id="main" class="%MAIN_CLASS%">
1975
1979
  <div id="header">
1976
1980
  <sl-icon-button name="list" style="font-size: 1.5rem; color: var(--sl-text);" onclick="toggleSidebar()"></sl-icon-button>
1977
1981
  </div>
violit/engine.py CHANGED
@@ -23,11 +23,24 @@ class WsEngine:
23
23
  def click_attrs(self, cid: str):
24
24
  return {"onclick": f"window.sendAction('{cid}')"}
25
25
 
26
- async def push_updates(self, sid: str, components: List[Component]):
26
+ async def push_updates(self, sid: str, components: List[Component], is_navigation: bool = False):
27
+ """Push component updates to client
28
+
29
+ Args:
30
+ sid: Session ID
31
+ components: List of components to update
32
+ is_navigation: If True, apply smooth page transition animation.
33
+ If False (default), update immediately without animation.
34
+ """
27
35
  if sid in self.sockets:
28
36
  payload = [{"id": c.id, "html": c.render()} for c in components]
29
- await self.sockets[sid].send_json({"type": "update", "payload": payload})
37
+ await self.sockets[sid].send_json({
38
+ "type": "update",
39
+ "payload": payload,
40
+ "isNavigation": is_navigation # Flag for client to determine animation
41
+ })
30
42
 
31
43
  async def push_eval(self, sid: str, code: str):
32
44
  if sid in self.sockets:
33
45
  await self.sockets[sid].send_json({"type": "eval", "code": code})
46
+
@@ -116,7 +116,7 @@ class ChatWidgetsMixin:
116
116
  <div class="chat-input-container" style="
117
117
  position: fixed;
118
118
  bottom: 0;
119
- left: 0;
119
+ left: 300px;
120
120
  right: 0;
121
121
  padding: 20px;
122
122
  background: linear-gradient(to top, var(--sl-bg) 80%, transparent);
@@ -124,6 +124,7 @@ class ChatWidgetsMixin:
124
124
  display: flex;
125
125
  justify-content: center;
126
126
  pointer-events: none;
127
+ transition: left 0.3s ease;
127
128
  ">
128
129
  <div style="
129
130
  width: 100%;