diff --git a/package.json b/package.json index 8e42344e7..16dce74e4 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "tailwindcss": "^3.4.0" }, "lint-staged": { - "*": "prettier --ignore-unknown --write" + "python/{api-examples-source,concept-source,tutorial-source}/**/*.{py,js,css,html}": "prettier --ignore-unknown --write --tab-width=4", + "!(python/{api-examples-source,concept-source,tutorial-source}/**/*.{py,js,css,html})": "prettier --ignore-unknown --write" } } diff --git a/python/concept-source/components-danger-button/danger_button_component/__init__.py b/python/concept-source/components-danger-button/danger_button_component/__init__.py new file mode 100644 index 000000000..f39638441 --- /dev/null +++ b/python/concept-source/components-danger-button/danger_button_component/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path +import streamlit as st + +component_dir = Path(__file__).parent + + +@st.cache_data +def load_component_code(): + with open(component_dir / "button.css", "r") as f: + CSS = f.read() + with open(component_dir / "button.html", "r") as f: + HTML = f.read() + with open(component_dir / "button.js", "r") as f: + JS = f.read() + return HTML, CSS, JS + + +HTML, CSS, JS = load_component_code() + +danger_button = st.components.v2.component( + name="hold_to_confirm", + html=HTML, + css=CSS, + js=JS, +) diff --git a/python/concept-source/components-danger-button/danger_button_component/button.css b/python/concept-source/components-danger-button/danger_button_component/button.css new file mode 100644 index 000000000..d1db972db --- /dev/null +++ b/python/concept-source/components-danger-button/danger_button_component/button.css @@ -0,0 +1,175 @@ +.danger-zone { + font-family: var(--st-font); + padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; +} + +.warning-banner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--st-red-background-color); + border: 1px solid var(--st-red-color); + border-radius: var(--st-base-radius); +} + +.warning-icon { + font-size: 1rem; +} + +.warning-text { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--st-red-color); +} + +.hold-button { + position: relative; + width: 7.5rem; + height: 7.5rem; + padding: 0 2rem; + border-radius: 50%; + border: 1px solid var(--st-primary-color); + background: var(--st-secondary-background-color); + cursor: pointer; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.hold-button:hover { + transform: scale(1.05); + border-color: var(--st-red-color); +} + +.hold-button:active:not(:disabled) { + transform: scale(0.98); +} + +.hold-button:disabled { + cursor: not-allowed; + opacity: 0.9; +} + +.hold-button.holding { + animation: pulse 0.5s ease-in-out infinite; + border-color: var(--st-red-color); +} + +.hold-button.triggered { + animation: success-burst 0.6s ease-out forwards; +} + +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 var(--st-red-color); + } + 50% { + box-shadow: 0 0 0 15px transparent; + } +} + +@keyframes success-burst { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.15); + background: var(--st-red-background-color); + } + 100% { + transform: scale(1); + } +} + +.progress-ring { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.ring-bg { + fill: none; + stroke: var(--st-border-color); + stroke-width: 4; +} + +.ring-progress { + fill: none; + stroke: var(--st-red-color); + stroke-width: 4; + stroke-linecap: round; + stroke-dasharray: 283; + stroke-dashoffset: 283; + transition: stroke-dashoffset 0.1s linear; + filter: drop-shadow(0 0 6px var(--st-red-color)); +} + +.button-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.icon { + font-size: 2rem; + transition: transform 0.3s ease; +} + +.hold-button:hover .icon { + transform: scale(1.1); +} + +.hold-button.holding .icon { + animation: shake 0.15s ease-in-out infinite; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-2px) rotate(-5deg); + } + 75% { + transform: translateX(2px) rotate(5deg); + } +} + +.label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--st-text-color); + opacity: 0.6; + transition: all 0.3s ease; +} + +.hold-button.holding .label { + color: var(--st-red-color); + opacity: 1; +} + +.hold-button.triggered .icon, +.hold-button.triggered .label { + color: var(--st-primary-color); + opacity: 1; +} + +.hint { + font-size: 0.7rem; + color: var(--st-text-color); + opacity: 0.5; + margin: 0; +} diff --git a/python/concept-source/components-danger-button/danger_button_component/button.html b/python/concept-source/components-danger-button/danger_button_component/button.html new file mode 100644 index 000000000..74b0eb25b --- /dev/null +++ b/python/concept-source/components-danger-button/danger_button_component/button.html @@ -0,0 +1,25 @@ +
+
+ ⚠️ + Danger Zone +
+ + + +

Press and hold for 2 seconds to confirm

+
diff --git a/python/concept-source/components-danger-button/danger_button_component/button.js b/python/concept-source/components-danger-button/danger_button_component/button.js new file mode 100644 index 000000000..a78ab4df3 --- /dev/null +++ b/python/concept-source/components-danger-button/danger_button_component/button.js @@ -0,0 +1,111 @@ +const HOLD_DURATION = 2000; // 2 seconds +const COOLDOWN_DURATION = 1500; // cooldown after trigger +const CIRCUMFERENCE = 2 * Math.PI * 45; // circle circumference + +export default function ({ parentElement, setTriggerValue }) { + const button = parentElement.querySelector("#danger-btn"); + const progress = parentElement.querySelector("#ring-progress"); + const icon = parentElement.querySelector("#icon"); + const label = parentElement.querySelector("#label"); + + let startTime = null; + let animationFrame = null; + let isDisabled = false; // Prevent interaction during cooldown + + function updateProgress() { + if (!startTime) return; + + const elapsed = Date.now() - startTime; + const progressPercent = Math.min(elapsed / HOLD_DURATION, 1); + const offset = CIRCUMFERENCE * (1 - progressPercent); + + progress.style.strokeDashoffset = offset; + + if (progressPercent >= 1) { + // Triggered! + triggerAction(); + } else { + animationFrame = requestAnimationFrame(updateProgress); + } + } + + function startHold() { + if (isDisabled) return; // Ignore if in cooldown + + startTime = Date.now(); + button.classList.add("holding"); + label.textContent = "Keep holding..."; + animationFrame = requestAnimationFrame(updateProgress); + } + + function cancelHold() { + if (isDisabled) return; // Ignore if in cooldown + + startTime = null; + button.classList.remove("holding"); + label.textContent = "Hold to Delete"; + progress.style.strokeDashoffset = CIRCUMFERENCE; + + if (animationFrame) { + cancelAnimationFrame(animationFrame); + animationFrame = null; + } + } + + function triggerAction() { + cancelAnimationFrame(animationFrame); + animationFrame = null; + startTime = null; + isDisabled = true; // Disable during cooldown + + button.classList.remove("holding"); + button.classList.add("triggered"); + button.disabled = true; + + icon.textContent = "✓"; + label.textContent = "Deleted!"; + progress.style.strokeDashoffset = 0; + + // Send trigger to Python + setTriggerValue("confirmed", true); + + // Reset after cooldown + setTimeout(() => { + button.classList.remove("triggered"); + button.disabled = false; + isDisabled = false; + icon.textContent = "🗑️"; + label.textContent = "Hold to Delete"; + progress.style.strokeDashoffset = CIRCUMFERENCE; + }, COOLDOWN_DURATION); + } + + function handleTouchStart(e) { + e.preventDefault(); + startHold(); + } + + // Mouse events + button.addEventListener("mousedown", startHold); + button.addEventListener("mouseup", cancelHold); + button.addEventListener("mouseleave", cancelHold); + + // Touch events for mobile + button.addEventListener("touchstart", handleTouchStart); + button.addEventListener("touchend", cancelHold); + button.addEventListener("touchcancel", cancelHold); + + return () => { + if (animationFrame) cancelAnimationFrame(animationFrame); + + // Remove mouse event listeners + button.removeEventListener("mousedown", startHold); + button.removeEventListener("mouseup", cancelHold); + button.removeEventListener("mouseleave", cancelHold); + + // Remove touch event listeners + button.removeEventListener("touchstart", handleTouchStart); + button.removeEventListener("touchend", cancelHold); + button.removeEventListener("touchcancel", cancelHold); + }; +} diff --git a/python/concept-source/components-danger-button/streamlit_app.py b/python/concept-source/components-danger-button/streamlit_app.py new file mode 100644 index 000000000..72d97a623 --- /dev/null +++ b/python/concept-source/components-danger-button/streamlit_app.py @@ -0,0 +1,27 @@ +import streamlit as st +from danger_button_component import danger_button + +st.title("Hold-to-Confirm Button") +st.caption("A dangerous action that requires intentional confirmation") + +# Track deletion events +if "deleted_items" not in st.session_state: + st.session_state.deleted_items = [] + +# Callback when deletion is confirmed +def on_delete_confirmed(): + st.session_state.deleted_items.append( + f"Deleted item #{len(st.session_state.deleted_items) + 1}" + ) + st.toast("🗑️ Item permanently deleted!", icon="⚠️") + + +# Render the component +result = danger_button(key="danger_btn", on_confirmed_change=on_delete_confirmed) + +# Show deletion history +if st.session_state.deleted_items: + st.divider() + st.subheader("Deletion Log") + for item in reversed(st.session_state.deleted_items[-3:]): + st.write(f"• {item}") \ No newline at end of file diff --git a/python/concept-source/components-hello-world.py b/python/concept-source/components-hello-world.py new file mode 100644 index 000000000..8aab3d5ff --- /dev/null +++ b/python/concept-source/components-hello-world.py @@ -0,0 +1,9 @@ +import streamlit as st + +hello_component = st.components.v2.component( + name="hello_world", + html="

Hello, World!

", + css="h2 { color: var(--st-primary-color); }", +) + +hello_component() diff --git a/python/concept-source/components-interactive-counter/my_component/__init__.py b/python/concept-source/components-interactive-counter/my_component/__init__.py new file mode 100644 index 000000000..a26f9d391 --- /dev/null +++ b/python/concept-source/components-interactive-counter/my_component/__init__.py @@ -0,0 +1,24 @@ +import streamlit as st +from pathlib import Path + +# Get the current file's directory +_COMPONENT_DIR = Path(__file__).parent + +@st.cache_data +def load_html(): + with open(_COMPONENT_DIR / "component.html", "r") as f: + return f.read() + +@st.cache_data +def load_css(): + with open(_COMPONENT_DIR / "component.css", "r") as f: + return f.read() + +@st.cache_data +def load_js(): + with open(_COMPONENT_DIR / "component.js", "r") as f: + return f.read() + +HTML = load_html() +CSS = load_css() +JS = load_js() diff --git a/python/concept-source/components-interactive-counter/my_component/component.css b/python/concept-source/components-interactive-counter/my_component/component.css new file mode 100644 index 000000000..6afc58e6e --- /dev/null +++ b/python/concept-source/components-interactive-counter/my_component/component.css @@ -0,0 +1,29 @@ +.counter { + padding: 20px; + border: 1px solid var(--st-border-color); + border-radius: var(--st-base-radius); + font-family: var(--st-font); + text-align: center; +} + +.buttons { + margin-top: 15px; +} + +button { + margin: 0 5px; + padding: 8px 16px; + background: var(--st-primary-color); + color: white; + border: none; + border-radius: var(--st-button-radius); + cursor: pointer; +} + +button:hover { + opacity: 0.8; +} + +#reset { + background: var(--st-red-color); +} diff --git a/python/concept-source/components-interactive-counter/my_component/component.html b/python/concept-source/components-interactive-counter/my_component/component.html new file mode 100644 index 000000000..5b0dc73b8 --- /dev/null +++ b/python/concept-source/components-interactive-counter/my_component/component.html @@ -0,0 +1,8 @@ +
+

Count: 0

+
+ + + +
+
diff --git a/python/concept-source/components-interactive-counter/my_component/component.js b/python/concept-source/components-interactive-counter/my_component/component.js new file mode 100644 index 000000000..c44717e1d --- /dev/null +++ b/python/concept-source/components-interactive-counter/my_component/component.js @@ -0,0 +1,43 @@ +export default function ({ + parentElement, + setStateValue, + setTriggerValue, + data, +}) { + let count = data?.initialCount || 0; + const display = parentElement.querySelector("#display"); + const incrementBtn = parentElement.querySelector("#increment"); + const decrementBtn = parentElement.querySelector("#decrement"); + const resetBtn = parentElement.querySelector("#reset"); + + const updateDisplay = () => { + display.textContent = count; + setStateValue("count", count); // Persistent state + }; + + incrementBtn.onclick = () => { + count++; + updateDisplay(); + }; + + decrementBtn.onclick = () => { + count--; + updateDisplay(); + }; + + resetBtn.onclick = () => { + count = 0; + updateDisplay(); + setTriggerValue("reset", true); // One-time trigger + }; + + // Initialize + updateDisplay(); + + // Cleanup function + return () => { + incrementBtn.removeEventListener("click", incrementBtn.onclick); + decrementBtn.removeEventListener("click", decrementBtn.onclick); + resetBtn.removeEventListener("click", resetBtn.onclick); + }; +} diff --git a/python/concept-source/components-interactive-counter/streamlit_app.py b/python/concept-source/components-interactive-counter/streamlit_app.py new file mode 100644 index 000000000..778c7cf3e --- /dev/null +++ b/python/concept-source/components-interactive-counter/streamlit_app.py @@ -0,0 +1,24 @@ +import streamlit as st +from my_component import HTML, CSS, JS + +# Interactive counter with both state and triggers +counter = st.components.v2.component( + "interactive_counter", + html=HTML, + css=CSS, + js=JS, +) + +# Use with callbacks +result = counter( + data={"initialCount": 0}, + on_count_change=lambda: None, # Track count state + on_reset_change=lambda: None, # Handle reset events +) + +# Display current state +st.write(f"Current count: {result.count}") + +# Show when reset was triggered (only for one rerun) +if result.reset: + st.toast("Counter was reset!") diff --git a/python/concept-source/components-radial-dial/radial_dial_component/__init__.py b/python/concept-source/components-radial-dial/radial_dial_component/__init__.py new file mode 100644 index 000000000..78dd41897 --- /dev/null +++ b/python/concept-source/components-radial-dial/radial_dial_component/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path +import streamlit as st + +component_dir = Path(__file__).parent + + +@st.cache_data +def load_component_code(): + with open(component_dir / "dial.css", "r") as f: + CSS = f.read() + with open(component_dir / "dial.html", "r") as f: + HTML = f.read() + with open(component_dir / "dial.js", "r") as f: + JS = f.read() + return HTML, CSS, JS + + +HTML, CSS, JS = load_component_code() + +radial_dial = st.components.v2.component( + name="radial_dial", + html=HTML, + css=CSS, + js=JS, +) diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.css b/python/concept-source/components-radial-dial/radial_dial_component/dial.css new file mode 100644 index 000000000..5a6ca37a4 --- /dev/null +++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.css @@ -0,0 +1,160 @@ +.dial-container { + font-family: var(--st-font); + color: var(--st-text-color); + display: flex; + flex-direction: column; + align-items: center; + padding: 1.5rem; + position: relative; +} + +.dial-svg { + width: 18rem; + height: 12rem; +} + +/* Track (background arc) */ +.dial-track { + fill: none; + stroke: var(--st-secondary-background-color); + stroke-width: 20; + stroke-linecap: round; +} + +/* Colored segments */ +.dial-segment { + fill: none; + stroke-width: 20; + stroke-linecap: round; + opacity: 0.15; +} + +.segment-low { + stroke: var(--st-green-color); +} +.segment-mid { + stroke: var(--st-yellow-color); +} +.segment-high { + stroke: var(--st-red-color); +} + +/* Progress arc */ +.dial-progress { + fill: none; + stroke: var(--st-green-color); + stroke-width: 20; + stroke-linecap: round; + filter: drop-shadow(0 0 8px var(--st-green-color)); + transition: + stroke-dashoffset 0.8s cubic-bezier(0.34, 1.2, 0.64, 1), + stroke 0.5s ease; +} + +/* Tick marks */ +.ticks line { + stroke: var(--st-text-color); + stroke-width: 2; + opacity: 0.3; +} + +.ticks line.major { + stroke-width: 3; + opacity: 0.5; +} + +/* Needle */ +.needle-group { + transform-origin: 100px 95px; + transition: transform 0.8s cubic-bezier(0.34, 1.2, 0.64, 1); +} + +.needle { + fill: var(--st-text-color); + filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3)); +} + +.needle-shadow { + fill: rgba(0, 0, 0, 0.2); + transform: translate(2px, 2px); +} + +.needle-cap { + fill: var(--st-secondary-background-color); + stroke: var(--st-border-color); + stroke-width: 2; +} + +.needle-cap-inner { + fill: var(--st-text-color); + opacity: 0.8; +} + +/* Value display */ +.value-display { + margin-top: -2rem; + text-align: center; +} + +.value { + font-family: var(--st-code-font); + font-size: 2.5rem; + font-weight: 700; + letter-spacing: -0.02em; + transition: color 0.5s ease; +} + +.unit { + font-size: 1rem; + font-weight: 500; + opacity: 0.6; + margin-left: 0.25rem; +} + +/* Labels */ +.labels { + display: flex; + justify-content: space-between; + width: 240px; + margin-top: 0.5rem; +} + +.label { + font-size: 0.75rem; + font-weight: 500; + opacity: 0.5; +} + +/* Title */ +.title { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.7; + margin-top: 0.75rem; +} + +/* Color themes based on value */ +.dial-progress.low { + stroke: var(--st-green-color); + filter: drop-shadow(0 0 8px var(--st-green-color)); +} +.dial-progress.mid { + stroke: var(--st-yellow-color); + filter: drop-shadow(0 0 8px var(--st-yellow-color)); +} +.dial-progress.high { + stroke: var(--st-red-color); + filter: drop-shadow(0 0 8px var(--st-red-color)); +} + +.value.low { + color: var(--st-green-color); +} +.value.mid { + color: var(--st-yellow-color); +} +.value.high { + color: var(--st-red-color); +} diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.html b/python/concept-source/components-radial-dial/radial_dial_component/dial.html new file mode 100644 index 000000000..c673e99e6 --- /dev/null +++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.html @@ -0,0 +1,40 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 0 + +
+ + +
+ 0 + 100 +
+ + +
+
diff --git a/python/concept-source/components-radial-dial/radial_dial_component/dial.js b/python/concept-source/components-radial-dial/radial_dial_component/dial.js new file mode 100644 index 000000000..f230b731c --- /dev/null +++ b/python/concept-source/components-radial-dial/radial_dial_component/dial.js @@ -0,0 +1,133 @@ +// Arc parameters +const CX = 100; +const CY = 95; +const RADIUS = 75; +const START_ANGLE = -135; // degrees from vertical +const END_ANGLE = 135; +const ANGLE_RANGE = END_ANGLE - START_ANGLE; // 270 degrees + +function polarToCartesian(angle) { + const rad = ((angle - 90) * Math.PI) / 180; + return { + x: CX + RADIUS * Math.cos(rad), + y: CY + RADIUS * Math.sin(rad), + }; +} + +function describeArc(start, end) { + const startPoint = polarToCartesian(start); + const endPoint = polarToCartesian(end); + const largeArc = end - start > 180 ? 1 : 0; + return `M ${startPoint.x} ${startPoint.y} A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${endPoint.x} ${endPoint.y}`; +} + +export default function ({ parentElement, data }) { + const track = parentElement.querySelector("#track"); + const progress = parentElement.querySelector("#progress"); + const segmentLow = parentElement.querySelector("#segment-low"); + const segmentMid = parentElement.querySelector("#segment-mid"); + const segmentHigh = parentElement.querySelector("#segment-high"); + const ticksGroup = parentElement.querySelector("#ticks"); + const needleGroup = parentElement.querySelector("#needle-group"); + const valueEl = parentElement.querySelector("#value"); + const unitEl = parentElement.querySelector("#unit"); + const minLabel = parentElement.querySelector("#min-label"); + const maxLabel = parentElement.querySelector("#max-label"); + const titleEl = parentElement.querySelector("#title"); + + // Draw background track + track.setAttribute("d", describeArc(START_ANGLE, END_ANGLE)); + + // Draw colored segments (thirds) + const third = ANGLE_RANGE / 3; + segmentLow.setAttribute("d", describeArc(START_ANGLE, START_ANGLE + third)); + segmentMid.setAttribute( + "d", + describeArc(START_ANGLE + third, START_ANGLE + 2 * third), + ); + segmentHigh.setAttribute( + "d", + describeArc(START_ANGLE + 2 * third, END_ANGLE), + ); + + // Draw tick marks + const numTicks = 10; + ticksGroup.innerHTML = ""; + for (let i = 0; i <= numTicks; i++) { + const angle = START_ANGLE + (ANGLE_RANGE * i) / numTicks; + const isMajor = i % 5 === 0; + const innerRadius = isMajor ? RADIUS - 30 : RADIUS - 25; + const outerRadius = RADIUS - 20; + + const rad = ((angle - 90) * Math.PI) / 180; + const x1 = CX + innerRadius * Math.cos(rad); + const y1 = CY + innerRadius * Math.sin(rad); + const x2 = CX + outerRadius * Math.cos(rad); + const y2 = CY + outerRadius * Math.sin(rad); + + const line = document.createElementNS( + "http://www.w3.org/2000/svg", + "line", + ); + line.setAttribute("x1", x1); + line.setAttribute("y1", y1); + line.setAttribute("x2", x2); + line.setAttribute("y2", y2); + if (isMajor) line.classList.add("major"); + ticksGroup.appendChild(line); + } + + function update(config) { + const { + value = 0, + min = 0, + max = 100, + min_label = String(min), + max_label = String(max), + unit = "", + title = "", + color_zones = true, + } = config; + + // Calculate percentage + const percent = Math.max(0, Math.min(1, (value - min) / (max - min))); + + // Calculate angle for this value + const valueAngle = START_ANGLE + ANGLE_RANGE * percent; + + // Update progress arc + progress.setAttribute("d", describeArc(START_ANGLE, valueAngle)); + + // Update needle rotation + needleGroup.style.transform = `rotate(${valueAngle}deg)`; + + // Determine color zone + let zone = "low"; + if (percent > 0.66) zone = "high"; + else if (percent > 0.33) zone = "mid"; + + // Apply color classes if color_zones enabled + progress.classList.remove("low", "mid", "high"); + valueEl.classList.remove("low", "mid", "high"); + + if (color_zones) { + progress.classList.add(zone); + valueEl.classList.add(zone); + } + + // Update text + valueEl.textContent = + typeof value === "number" + ? Number.isInteger(value) + ? value + : value.toFixed(1) + : value; + unitEl.textContent = unit; + minLabel.textContent = min_label; + maxLabel.textContent = max_label; + titleEl.textContent = title; + } + + // Initial render + update(data || {}); +} diff --git a/python/concept-source/components-radial-dial/streamlit_app.py b/python/concept-source/components-radial-dial/streamlit_app.py new file mode 100644 index 000000000..a032a0755 --- /dev/null +++ b/python/concept-source/components-radial-dial/streamlit_app.py @@ -0,0 +1,20 @@ +import streamlit as st +from radial_dial_component import radial_dial + +st.title("Radial Dial") +st.caption("A display-only component with smooth transitions") + +st.subheader("CPU Temperature") +temp = st.slider("Adjust temperature", 30, 100, 45) +radial_dial( + key="temp_dial", + data={ + "value": temp, + "min": 30, + "max": 100, + "min_label": "Cool", + "max_label": "Hot", + "unit": "°C", + "title": "CPU Temperature" + } +) \ No newline at end of file diff --git a/python/concept-source/components-radial-menu/radial_menu_component/__init__.py b/python/concept-source/components-radial-menu/radial_menu_component/__init__.py new file mode 100644 index 000000000..52f033809 --- /dev/null +++ b/python/concept-source/components-radial-menu/radial_menu_component/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path +import streamlit as st + +component_dir = Path(__file__).parent + + +@st.cache_data +def load_component_code(): + with open(component_dir / "menu.css", "r") as f: + CSS = f.read() + with open(component_dir / "menu.html", "r") as f: + HTML = f.read() + with open(component_dir / "menu.js", "r") as f: + JS = f.read() + return HTML, CSS, JS + + +HTML, CSS, JS = load_component_code() + +radial_menu = st.components.v2.component( + name="radial_menu", + html=HTML, + css=CSS, + js=JS, +) diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.css b/python/concept-source/components-radial-menu/radial_menu_component/menu.css new file mode 100644 index 000000000..e583eafa4 --- /dev/null +++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.css @@ -0,0 +1,112 @@ +.radial-menu { + position: relative; + display: inline-block; + font-family: var(--st-font); +} + +/* The circular selector button and menu items*/ +.menu-selector, +.menu-item { + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + border: 2px solid var(--st-border-color); + cursor: pointer; + background: var(--st-secondary-background-color); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + font-size: 1.5rem; +} + +.menu-selector:hover { + transform: scale(1.05); + border-color: var(--st-primary-color); +} + +/* Overlay container */ +.menu-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 100; + pointer-events: none; +} + +/* The ring of menu items */ +.menu-ring { + position: relative; + width: 13rem; + height: 13rem; + transform: scale(0); + opacity: 0; + transition: + transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.2s ease; +} + +.menu-ring.open { + transform: scale(1); + opacity: 1; + pointer-events: auto; +} + +/* Menu items arranged in a circle (6 items at 60 degree intervals)*/ +.menu-item { + --angle: calc(var(--i) * 60deg - 90deg); + + background: var(--st-background-color); + position: absolute; + top: 50%; + left: 50%; + margin: -1.6125rem; + transform: rotate(var(--angle)) translateX(4rem) + rotate(calc(-1 * var(--angle))); +} + +.menu-item:hover { + transform: rotate(var(--angle)) translateX(4rem) + rotate(calc(-1 * var(--angle))) scale(1.15); + border-color: var(--st-primary-color); + background: var(--st-secondary-background-color); +} + +.menu-item.selected { + border-color: var(--st-primary-color); + background: var(--st-secondary-background-color); +} + +/* Backdrop when menu is open */ +.menu-overlay::before { + content: ""; + position: fixed; + inset: -100vh -100vw; + background: var(--st-background-color); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: -1; +} + +.menu-overlay.open::before { + opacity: 0.7; + pointer-events: auto; +} + +/* Center decoration */ +.menu-ring::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 2rem; + height: 2rem; + transform: translate(-50%, -50%); + border-radius: 50%; + background: var(--st-secondary-background-color); + border: 2px dashed var(--st-border-color); + opacity: 0.6; + box-sizing: border-box; +} diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.html b/python/concept-source/components-radial-menu/radial_menu_component/menu.html new file mode 100644 index 000000000..4f860918c --- /dev/null +++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.html @@ -0,0 +1,11 @@ +
+ + + +
diff --git a/python/concept-source/components-radial-menu/radial_menu_component/menu.js b/python/concept-source/components-radial-menu/radial_menu_component/menu.js new file mode 100644 index 000000000..2bca0b4c7 --- /dev/null +++ b/python/concept-source/components-radial-menu/radial_menu_component/menu.js @@ -0,0 +1,58 @@ +export default function ({ parentElement, data, setStateValue }) { + const selector = parentElement.querySelector("#selector"); + const selectorIcon = parentElement.querySelector("#selector-icon"); + const overlay = parentElement.querySelector("#overlay"); + const ring = parentElement.querySelector("#ring"); + + let isOpen = false; + const options = data?.options || {}; + let currentSelection = data?.selection || Object.keys(options)[0]; + + // Create the 6 menu items from options + Object.entries(options).forEach(([value, icon], index) => { + const button = document.createElement("button"); + button.className = "menu-item"; + button.dataset.value = value; + button.style.setProperty("--i", index); + button.textContent = icon; + + button.addEventListener("click", () => { + currentSelection = value; + updateDisplay(); + toggleMenu(); + setStateValue("selection", currentSelection); + }); + + ring.appendChild(button); + }); + + // Update the selector icon and highlight selected item + function updateDisplay() { + selectorIcon.textContent = options[currentSelection] || "?"; + + ring.querySelectorAll(".menu-item").forEach((item) => { + item.classList.toggle( + "selected", + item.dataset.value === currentSelection, + ); + }); + } + + // Toggle menu open/closed + function toggleMenu() { + isOpen = !isOpen; + overlay.classList.toggle("open", isOpen); + ring.classList.toggle("open", isOpen); + } + + // Initialize display + updateDisplay(); + + // Selector click toggles menu + selector.addEventListener("click", toggleMenu); + + // Click outside closes menu + overlay.addEventListener("click", (e) => { + if (e.target === overlay) toggleMenu(); + }); +} diff --git a/python/concept-source/components-radial-menu/streamlit_app.py b/python/concept-source/components-radial-menu/streamlit_app.py new file mode 100644 index 000000000..1c18581ad --- /dev/null +++ b/python/concept-source/components-radial-menu/streamlit_app.py @@ -0,0 +1,26 @@ +import streamlit as st +from radial_menu_component import radial_menu + +st.header("Radial Menu Component") + +st.write("Click the button to open the menu. Select your favorite food!") + +options = { + "pizza": "🍕", + "burger": "🍔", + "taco": "🌮", + "ramen": "🍜", + "sushi": "🍣", + "salad": "🥗", +} + +result = radial_menu( + data={"options": options, "selection": "burger"}, + default={"selection": "burger"}, + on_selection_change=lambda: None, + key="food_menu", +) + +if result.selection: + icon = options.get(result.selection, "") + st.write(f"You selected: **{icon} {result.selection.title()}**") \ No newline at end of file diff --git a/python/concept-source/components-rich-data.py b/python/concept-source/components-rich-data.py new file mode 100644 index 000000000..ad74b6d05 --- /dev/null +++ b/python/concept-source/components-rich-data.py @@ -0,0 +1,61 @@ +import pandas as pd +import streamlit as st +import base64 +from pathlib import Path + + +_APP_DIR = Path(__file__).parent + + +# Create sample data +@st.cache_data +def create_sample_df(): + return pd.DataFrame( + { + "name": ["Alice", "Bob", "Charlie"], + "city": ["New York", "London", "Tokyo"], + } + ) + + +df = create_sample_df() + + +# Load an image and convert to b64 string +@st.cache_data +def load_image_as_base64(image_path): + with open(image_path, "rb") as img_file: + img_bytes = img_file.read() + return base64.b64encode(img_bytes).decode("utf-8") + + +img_base64 = load_image_as_base64(_APP_DIR / "favi.png") + +# Serialization is automatically handled by Streamlit components +chart_component = st.components.v2.component( + "data_display", + html="""
Loading data...
""", + js=""" + export default function({ data, parentElement }) { + const container = parentElement.querySelector("#data-container"); + + const df = data.df; + const userInfo = data.user_info; + const imgBase64 = data.image_base64; + + container.innerHTML = ` +

Dataframe: ${df}

+

User Info: ${userInfo.name}

+ + `; + } + """, +) + +chart_component( + data={ + "df": df, # Arrow-serializable dataframe + "user_info": {"name": "Alice"}, # JSON-serializable data + "image_base64": img_base64, # Image as base64 string + } +) diff --git a/python/concept-source/components-simple-button.py b/python/concept-source/components-simple-button.py new file mode 100644 index 000000000..9e6d42224 --- /dev/null +++ b/python/concept-source/components-simple-button.py @@ -0,0 +1,37 @@ +import streamlit as st + +if "click_count" not in st.session_state: + st.session_state.click_count = 0 + + +def handle_button_click(): + st.session_state.click_count += 1 + + +my_component = st.components.v2.component( + "interactive_button", + html="""""", + css=""" + button { + border: none; + padding: .5rem; + border-radius: var(--st-button-radius); + background-color: var(--st-primary-color); + color: white; + } + """, + js=""" + export default function(component) { + const { setTriggerValue, parentElement } = component; + + parentElement.querySelector("button").onclick = () => { + setTriggerValue("action", "button_clicked"); + }; + } + """, +) + +result = my_component(on_action_change=handle_button_click) + +if result.action: + st.write(f"Button clicked! Total clicks: {st.session_state.click_count}") diff --git a/python/concept-source/components-simple-counter/my_component/__init__.py b/python/concept-source/components-simple-counter/my_component/__init__.py new file mode 100644 index 000000000..a26f9d391 --- /dev/null +++ b/python/concept-source/components-simple-counter/my_component/__init__.py @@ -0,0 +1,24 @@ +import streamlit as st +from pathlib import Path + +# Get the current file's directory +_COMPONENT_DIR = Path(__file__).parent + +@st.cache_data +def load_html(): + with open(_COMPONENT_DIR / "component.html", "r") as f: + return f.read() + +@st.cache_data +def load_css(): + with open(_COMPONENT_DIR / "component.css", "r") as f: + return f.read() + +@st.cache_data +def load_js(): + with open(_COMPONENT_DIR / "component.js", "r") as f: + return f.read() + +HTML = load_html() +CSS = load_css() +JS = load_js() diff --git a/python/concept-source/components-simple-counter/my_component/component.css b/python/concept-source/components-simple-counter/my_component/component.css new file mode 100644 index 000000000..3a16535ae --- /dev/null +++ b/python/concept-source/components-simple-counter/my_component/component.css @@ -0,0 +1,25 @@ +.counter { + padding: 0.5rem 0.5rem; + border: 1px solid var(--st-border-color); + border-radius: var(--st-base-radius); + font-family: var(--st-font); + font-size: var(--st-base-font-size); + color: var(--st-text-color); +} + +#count { + padding: 0.75rem; +} + +#increment { + background: var(--st-primary-color); + color: white; + border: none; + border-radius: var(--st-button-radius); + padding: 0.25rem 0.5rem; + margin-left: 0.25rem; +} + +#increment:hover { + opacity: 0.8; +} diff --git a/python/concept-source/components-simple-counter/my_component/component.html b/python/concept-source/components-simple-counter/my_component/component.html new file mode 100644 index 000000000..36ce6c0bb --- /dev/null +++ b/python/concept-source/components-simple-counter/my_component/component.html @@ -0,0 +1,4 @@ +
+ 0 + +
diff --git a/python/concept-source/components-simple-counter/my_component/component.js b/python/concept-source/components-simple-counter/my_component/component.js new file mode 100644 index 000000000..c4c5f0e61 --- /dev/null +++ b/python/concept-source/components-simple-counter/my_component/component.js @@ -0,0 +1,11 @@ +export default function ({ parentElement, setStateValue }) { + let count = 0; + const display = parentElement.querySelector("#count"); + const button = parentElement.querySelector("#increment"); + + button.onclick = () => { + count++; + display.textContent = count; + setStateValue("count", count); + }; +} diff --git a/python/concept-source/components-simple-counter/streamlit_app.py b/python/concept-source/components-simple-counter/streamlit_app.py new file mode 100644 index 000000000..2815ab3e7 --- /dev/null +++ b/python/concept-source/components-simple-counter/streamlit_app.py @@ -0,0 +1,24 @@ +import streamlit as st +from my_component import HTML, CSS, JS + +counter_component = st.components.v2.component( + name="counter", html=HTML, css=CSS, js=JS +) + + +# Define callback function for the count state value +def handle_count_change(): + # Called when the component calls setStateValue("count", value) + st.toast("Count was updated!") + + +# Mount the counter component with callback +result = counter_component( + width="content", on_count_change=handle_count_change, key="counter_1" +) + +# Access the current count value +st.write(f"Current count: {result.count}") + +# Access the current count value in Session State +st.write(f"Current count: {st.session_state.counter_1.count}") diff --git a/python/concept-source/components-stopwatch/stopwatch_component/__init__.py b/python/concept-source/components-stopwatch/stopwatch_component/__init__.py new file mode 100644 index 000000000..f922b03a9 --- /dev/null +++ b/python/concept-source/components-stopwatch/stopwatch_component/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path +import streamlit as st + +component_dir = Path(__file__).parent + + +@st.cache_data +def load_component_code(): + with open(component_dir / "stopwatch.css", "r") as f: + CSS = f.read() + with open(component_dir / "stopwatch.html", "r") as f: + HTML = f.read() + with open(component_dir / "stopwatch.js", "r") as f: + JS = f.read() + return HTML, CSS, JS + + +HTML, CSS, JS = load_component_code() + +stopwatch = st.components.v2.component( + name="stopwatch", + html=HTML, + css=CSS, + js=JS +) \ No newline at end of file diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css new file mode 100644 index 000000000..2e15a231a --- /dev/null +++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.css @@ -0,0 +1,205 @@ +.stopwatch { + font-family: var(--st-font); + color: var(--st-text-color); + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + gap: 2rem; +} + +/* Ring Display */ +.display-ring { + position: relative; + width: 14rem; + height: 14rem; +} + +.ring-svg { + position: absolute; + inset: -0.75rem; + padding: 0.75rem; + transform: rotate(-90deg); + overflow: visible; +} + +.ring-track, +.ring-progress { + fill: none; + stroke-width: 6; +} + +.ring-track { + stroke: var(--st-secondary-background-color); +} + +.ring-progress { + stroke: var(--st-primary-color); + stroke-linecap: round; + stroke-dasharray: 565.5; + stroke-dashoffset: 565.5; + transition: stroke-dashoffset 0.1s linear; + filter: drop-shadow(0 0 8px var(--st-primary-color)); +} + +.ring-progress.running { + animation: glow 2s ease-in-out infinite; +} + +@keyframes glow { + 0%, + 100% { + opacity: 0.7; + } + 50% { + opacity: 1; + } +} + +/* Time Display */ +.display { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: baseline; + gap: 2px; + font-family: var(--st-code-font); + font-size: 2.5rem; + font-weight: 700; +} + +.time-segment { + min-width: 2ch; + text-align: center; + letter-spacing: 0.05em; +} + +.separator { + opacity: 0.5; +} + +.time-segment.small, +.separator.small { + font-size: 1.5rem; + font-weight: 500; +} + +.time-segment.small { + opacity: 0.7; +} + +/* Controls */ +.controls { + display: flex; + gap: 1rem; + align-items: center; +} + +.ctrl-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 0.75rem 1.25rem; + border: none; + border-radius: var(--st-button-radius); + cursor: pointer; + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); + min-width: 5rem; +} + +.ctrl-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ctrl-btn:hover:not(:disabled) { + transform: scale(1.05); +} + +.ctrl-btn.primary { + background: var(--st-primary-color); + color: white; +} + +.ctrl-btn.primary:hover:not(:disabled) { + filter: brightness(1.1); +} + +.ctrl-btn.secondary { + background: var(--st-secondary-background-color); + border: 1px solid var(--st-border-color); +} + +.ctrl-btn.secondary:hover:not(:disabled) { + border-color: var(--st-primary-color); +} + +.btn-icon { + font-size: 1.25rem; + line-height: 1; +} + +.btn-label { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Lap List */ +.lap-list { + width: 100%; + max-width: 280px; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 150px; + overflow-y: auto; +} + +.lap-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background: var(--st-secondary-background-color); + border-radius: var(--st-base-radius); + font-size: 0.85rem; + animation: slide-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.lap-number { + color: var(--st-primary-color); + font-weight: 600; +} + +.lap-time, +.lap-delta { + font-family: var(--st-code-font); + font-size: 0.8rem; + opacity: 0.8; +} + +.lap-delta.fastest { + color: var(--st-green-color); + opacity: 1; +} + +.lap-delta.slowest { + color: var(--st-red-color); + opacity: 1; +} diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html new file mode 100644 index 000000000..614fd6cf8 --- /dev/null +++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.html @@ -0,0 +1,38 @@ +
+
+ + + + +
+ 00 + : + 00 + . + 00 +
+
+ +
+ + + +
+ +
+
diff --git a/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js new file mode 100644 index 000000000..9957c64d7 --- /dev/null +++ b/python/concept-source/components-stopwatch/stopwatch_component/stopwatch.js @@ -0,0 +1,218 @@ +export default function ({ + parentElement, + data, + setStateValue, + setTriggerValue, +}) { + const minutes = parentElement.querySelector("#minutes"); + const seconds = parentElement.querySelector("#seconds"); + const centiseconds = parentElement.querySelector("#centiseconds"); + const ringProgress = parentElement.querySelector("#ring-progress"); + const startBtn = parentElement.querySelector("#start-btn"); + const lapBtn = parentElement.querySelector("#lap-btn"); + const resetBtn = parentElement.querySelector("#reset-btn"); + const lapList = parentElement.querySelector("#lap-list"); + + const CIRCUMFERENCE = 2 * Math.PI * 90; + + // Initialize from state or defaults + let elapsedMs = data?.elapsed || 0; + let isRunning = data?.running || false; + let laps = data?.laps || []; + let lastTimestamp = null; + let animationFrame = null; + + let lastMinute = Math.floor(elapsedMs / 60000); + let isTransitioning = false; + + function formatTime(ms) { + const totalSeconds = Math.floor(ms / 1000); + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + const cents = Math.floor((ms % 1000) / 10); + return { + mins: String(mins).padStart(2, "0"), + secs: String(secs).padStart(2, "0"), + cents: String(cents).padStart(2, "0"), + }; + } + + function updateDisplay() { + const time = formatTime(elapsedMs); + minutes.textContent = time.mins; + seconds.textContent = time.secs; + centiseconds.textContent = time.cents; + + const currentMinute = Math.floor(elapsedMs / 60000); + const secondsInMinute = (elapsedMs % 60000) / 1000; + + // Arc length: 0 at second 0, full circle at second 60 + const arcLength = (secondsInMinute / 60) * CIRCUMFERENCE; + + // Detect minute boundary - quick fade transition + if (currentMinute > lastMinute && !isTransitioning) { + lastMinute = currentMinute; + isTransitioning = true; + + // Quick fade out + ringProgress.style.transition = "opacity 0.15s ease-out"; + ringProgress.style.opacity = "0"; + + setTimeout(() => { + // Reset to small arc while invisible + ringProgress.style.transition = "none"; + ringProgress.style.strokeDasharray = `${arcLength} ${CIRCUMFERENCE}`; + ringProgress.style.strokeDashoffset = 0; + + // Fade back in + requestAnimationFrame(() => { + ringProgress.style.transition = "opacity 0.15s ease-in"; + ringProgress.style.opacity = "1"; + + setTimeout(() => { + ringProgress.style.transition = ""; + isTransitioning = false; + }, 150); + }); + }, 150); + } + + // Normal ring update + if (!isTransitioning) { + ringProgress.style.strokeDasharray = `${arcLength} ${CIRCUMFERENCE}`; + ringProgress.style.strokeDashoffset = 0; + } + } + + function updateButtons() { + startBtn.querySelector(".btn-icon").textContent = isRunning + ? "⏸" + : "▶"; + startBtn.querySelector(".btn-label").textContent = isRunning + ? "Pause" + : "Start"; + startBtn.classList.toggle("running", isRunning); + ringProgress.classList.toggle("running", isRunning); + + lapBtn.disabled = !isRunning; + resetBtn.disabled = isRunning || elapsedMs === 0; + } + + function renderLaps() { + lapList.innerHTML = ""; + + if (laps.length === 0) return; + + // Calculate deltas and find fastest/slowest + const deltas = laps.map((lap, i) => { + return i === 0 ? lap : lap - laps[i - 1]; + }); + + const minDelta = Math.min(...deltas); + const maxDelta = Math.max(...deltas); + + // Render in reverse (newest first) + [...laps].reverse().forEach((lap, reverseIdx) => { + const idx = laps.length - 1 - reverseIdx; + const delta = deltas[idx]; + const time = formatTime(lap); + const deltaTime = formatTime(delta); + + let deltaClass = ""; + if (laps.length > 1) { + if (delta === minDelta) deltaClass = "fastest"; + else if (delta === maxDelta) deltaClass = "slowest"; + } + + const item = document.createElement("div"); + item.className = "lap-item"; + item.innerHTML = ` + Lap ${idx + 1} + +${deltaTime.mins}:${deltaTime.secs}.${deltaTime.cents} + ${time.mins}:${time.secs}.${time.cents} + `; + lapList.appendChild(item); + }); + } + + function tick(timestamp) { + if (!lastTimestamp) lastTimestamp = timestamp; + + const delta = timestamp - lastTimestamp; + lastTimestamp = timestamp; + + elapsedMs += delta; + updateDisplay(); + + if (isRunning) { + animationFrame = requestAnimationFrame(tick); + } + } + + function start() { + isRunning = true; + lastTimestamp = null; + animationFrame = requestAnimationFrame(tick); + updateButtons(); + setStateValue("running", true); + } + + function pause() { + isRunning = false; + if (animationFrame) { + cancelAnimationFrame(animationFrame); + animationFrame = null; + } + updateButtons(); + setStateValue("running", false); + setStateValue("elapsed", elapsedMs); + } + + function recordLap() { + laps.push(elapsedMs); + renderLaps(); + setStateValue("laps", laps); + const t = formatTime(elapsedMs); + setTriggerValue("lap", { + number: laps.length, + time: elapsedMs, + formatted: `${t.mins}:${t.secs}.${t.cents}`, + }); + } + + function reset() { + elapsedMs = 0; + laps = []; + updateDisplay(); + renderLaps(); + updateButtons(); + setStateValue("laps", []); + setStateValue("elapsed", 0); + setStateValue("running", false); + setTriggerValue("reset", true); + } + + // Event listeners + startBtn.addEventListener("click", () => { + if (isRunning) pause(); + else start(); + }); + + lapBtn.addEventListener("click", recordLap); + resetBtn.addEventListener("click", reset); + + // Initialize display + updateDisplay(); + updateButtons(); + renderLaps(); + + // Resume if was running + if (isRunning) { + lastTimestamp = null; + animationFrame = requestAnimationFrame(tick); + } + + return () => { + if (animationFrame) cancelAnimationFrame(animationFrame); + }; +} diff --git a/python/concept-source/components-stopwatch/streamlit_app.py b/python/concept-source/components-stopwatch/streamlit_app.py new file mode 100644 index 000000000..4161ed875 --- /dev/null +++ b/python/concept-source/components-stopwatch/streamlit_app.py @@ -0,0 +1,32 @@ +import streamlit as st +from stopwatch_component import stopwatch + +st.title("Stopwatch with Laps") +st.caption("Combining state values (time, running) with trigger values (lap, reset)") + +# Track laps in Python +if "laps" not in st.session_state: + st.session_state.laps = [] + +# Render the component +result = stopwatch( + key="stopwatch", + on_lap_change=lambda: None, + on_reset_change=lambda: None, + on_running_change=lambda: None, + on_elapsed_change=lambda: None, + on_laps_change=lambda: None, + default={"elapsed": 0, "running": False, "laps": []}, +) + +# Display state info +col1, col2 = st.columns(2) +with col1: + st.metric("Status", "Running" if result.running else "Paused") + elapsed_sec = (result.elapsed or 0) / 1000 + st.metric("Elapsed", f"{elapsed_sec:.1f}s") +with col2: + st.subheader("Lap Records (Python)") + for i, lap_ms in enumerate(result.laps[-5:]): + mins, secs = divmod(lap_ms / 1000, 60) + st.write(f"**Lap {i+1}**: {int(mins):02d}:{secs:05.2f}") \ No newline at end of file diff --git a/python/concept-source/favi.png b/python/concept-source/favi.png new file mode 100644 index 000000000..7ffb2bce0 Binary files /dev/null and b/python/concept-source/favi.png differ