Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/action2v/tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from numpy import uint64

from flashdreams.api_v2.client_window import IClientWindow
from flashdreams.api_v2.user_input_event import UserInputEvent
from flashdreams.runtime_v2.session_desc import SessionDesc
from flashdreams.runtime_v2.session_runner import run_session
from flashdreams.runtime_v2.step_result import StepResult
Expand Down Expand Up @@ -138,6 +139,9 @@ def __init__(self) -> None:
self.opened = False
self.cursor_requests: list[tuple[str, bool]] = []

def _put_user_input_event(self, event: UserInputEvent) -> None:
del event

def open(self, session_desc: SessionDesc) -> None:
del session_desc
self.opened = True
Expand Down
4 changes: 4 additions & 0 deletions apps/t2v/t2v/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import torch

from flashdreams.api_v2.client_window import IClientWindow
from flashdreams.api_v2.user_input_event import UserInputEvent
from flashdreams.runtime_v2.blit_model_output_to_screen_loop import (
BlitModelOutputToScreenLoop,
)
Expand Down Expand Up @@ -410,6 +411,9 @@ class _DiscardingClientWindow(IClientWindow):
def get_user_input_events(self) -> UserInputEvents:
return UserInputEvents([])

def _put_user_input_event(self, event: UserInputEvent) -> None:
del event

def open(self, session_desc: SessionDesc) -> None:
del session_desc

Expand Down
4 changes: 4 additions & 0 deletions apps/t2v/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from flashdreams.api_v2.client_window import IClientWindow
from flashdreams.api_v2.loop import IModelLoop
from flashdreams.api_v2.session import ISession
from flashdreams.api_v2.user_input_event import UserInputEvent
from flashdreams.runtime_v2 import cli
from flashdreams.runtime_v2.application_registry import (
APPLICATION_ENTRY_POINT_GROUP,
Expand Down Expand Up @@ -160,6 +161,9 @@ def __init__(self) -> None:
def get_user_input_events(self) -> UserInputEvents:
return UserInputEvents([])

def _put_user_input_event(self, event: UserInputEvent) -> None:
del event

def open(self, session_desc: SessionDesc) -> None:
self.session_desc = session_desc

Expand Down
8 changes: 7 additions & 1 deletion flashdreams/flashdreams/api_v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,13 @@ UI control that needs a fresh application session calls
`request_new_session(session_desc)` with a fully resolved replacement
description; the runtime cleans the current session and passes that description
to `ApplicationRunner` unchanged. Remaining timeout and remaining model steps
still apply to the replacement.
still apply to the replacement. To let the user pick a client file, call
`request_file_selector(request_id, ...)` when they ask (for ImGui, when
`imgui.button(...)` returns `True`), with one stable `request_id` per control.
The result arrives on a later tick as one `SelectedFilesUserInputEvent`
carrying that `request_id`; read its `status` before its `files`. One picker
is open at a time, and a call made while one is open is answered `BUSY`. The
`request_file_selector` docstring lists every status.

For SlangPy's smaller retained widget API, subclass `SlangPyUILoop` from
`flashdreams.runtime_v2.slangpy_ui_loop`. The
Expand Down
145 changes: 143 additions & 2 deletions flashdreams/flashdreams/api_v2/client_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,29 @@

"""Client window abstract interface."""

import logging
import math
from abc import ABC
from dataclasses import dataclass
from abc import ABC, abstractmethod
from dataclasses import dataclass, replace
from pathlib import Path
from typing import final
from urllib.parse import urlsplit

from numpy import uint64

from flashdreams.runtime_v2.selected_file import (
FileSelectionRequest,
SelectedFile,
SelectedFilesStatus,
selected_files_policy_status,
)
from flashdreams.runtime_v2.user_input_event import SelectedFilesUserInputEvent

from .input_source import InputSource
from .output_sink import OutputSink
from .user_input_event import UserInputEvent

_LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -89,6 +105,10 @@ class IClientWindow(InputSource, OutputSink, ABC):
One thread makes every call on a window, so an implementation needs no
locking except when its backend delivers input from another thread.

A window with a file picker implements :meth:`_request_file_selector`, and
:meth:`_cancel_active_file_selectors` if it can withdraw a shown picker. It
reports results on the UI thread.

Created by the runtime, never by an application.
"""

Expand Down Expand Up @@ -117,3 +137,124 @@ def request_new_window_size(self, new_window_size: tuple[int, int]) -> None:
new_window_size: Requested ``(width, height)`` in pixels.
"""
pass

# File Selector Methods & Members
_active_file_selector: tuple[int, FileSelectionRequest] | None = None
_last_file_selector_id = 0

@final
def request_file_selector(self, request: FileSelectionRequest) -> None:
Comment thread
spaingnv marked this conversation as resolved.
"""Request for IClientWindow to open a file selector; the result of the
user interaction arrives later as a ``SelectedFilesUserInputEvent`` via
:meth:`InputSource.get_user_input_events`.

A request made while a picker is open is answered with a status of
``SelectedFilesStatus.BUSY``.

Args:
request: Selector request queued by the UI loop.
"""
if self._active_file_selector is not None:
self._put_user_input_event(
_selected_files_event(request, SelectedFilesStatus.BUSY)
)
return
selector_id = self._last_file_selector_id + 1
self._last_file_selector_id = selector_id
self._active_file_selector = (selector_id, request)
try:
self._request_file_selector(selector_id, request)
except Exception:
_LOGGER.exception("File selector %r failed to start.", request.request_id)
self._complete_file_selector(selector_id, SelectedFilesStatus.UNAVAILABLE)

@final
def flush_file_selectors(self) -> None:
"""Forget the open picker without a result, for a session that ended.

The window withdraws a picker still on screen where it can; whatever
that picker returns is dropped.
"""
active = self._active_file_selector
self._active_file_selector = None
if active is not None:
self._cancel_active_file_selectors()

# Optional to implement
def _request_file_selector(
self, selector_id: int, request: FileSelectionRequest
) -> None:
"""Show a picker for ``request`` without blocking.

Call :meth:`_complete_file_selector` with ``selector_id`` on the UI
thread when the user finishes. The default has no picker and completes
as ``UNAVAILABLE``.
"""
del request
self._complete_file_selector(selector_id, SelectedFilesStatus.UNAVAILABLE)

# Optional to implement
def _cancel_active_file_selectors(self) -> None:
"""Withdraw the picker that was flushed."""
pass

@abstractmethod
def _put_user_input_event(self, event: UserInputEvent) -> None:
Comment on lines +201 to +202

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Controller modes cannot start

Adding abstract _put_user_input_event makes the existing ControllerSourceWindow and its subclass IsaacTeleopWebRTCWindow impossible to create because neither implements it. The controller modes still construct these classes, so they now fail at startup with TypeError, even without requesting a file.

Update these implementations alongside the interface change.

"""Queue ``event`` for :meth:`InputSource.get_user_input_events`.

Canonical method for how user-input-events are added to a window.
"""

@final
def _complete_file_selector(
self,
selector_id: int,
status: SelectedFilesStatus,
files: tuple[SelectedFile, ...] = (),
) -> None:
"""Report the result for ``selector_id``; a stale id is dropped.

A result that breaks the request's rules (type, size, count) is
reported by the status it earns instead.
"""
active = self._active_file_selector
if active is None or active[0] != selector_id:
return
self._active_file_selector = None
request = active[1]
status, files = _check_file_selection_result(request, status, files)
self._put_user_input_event(_selected_files_event(request, status, files))


def _selected_files_event(
request: FileSelectionRequest,
status: SelectedFilesStatus,
files: tuple[SelectedFile, ...] = (),
) -> SelectedFilesUserInputEvent:
"""Return the event answering ``request``."""
return SelectedFilesUserInputEvent(
timestamp=uint64(0),
request_id=request.request_id,
status=status,
files=files,
)


def _check_file_selection_result(
request: FileSelectionRequest,
status: SelectedFilesStatus,
files: tuple[SelectedFile, ...],
) -> tuple[SelectedFilesStatus, tuple[SelectedFile, ...]]:
"""Return the status and files a window's result earns under ``request``."""
if status is not SelectedFilesStatus.OK:
return status, ()
if not files:
_LOGGER.warning("File selector %r returned no valid set.", request.request_id)
return SelectedFilesStatus.UNAVAILABLE, ()
files = tuple(replace(chosen, name=Path(chosen.name).name) for chosen in files)
rejected = selected_files_policy_status(
[(chosen.name, len(chosen.data)) for chosen in files], request
)
if rejected is not None:
return rejected, ()
return status, files
Loading
Loading