Coverage for pyWebLayout/concrete/interaction_handler.py: 0%

99 statements  

« prev     ^ index     » next       coverage.py v7.11.2, created at 2025-11-12 12:02 +0000

1""" 

2Interaction handler for managing button/link press-release lifecycle with visual feedback. 

3 

4This module provides utilities for handling interactive element states and rendering 

5frames at different stages of interaction (pressed, released). 

6""" 

7 

8from typing import Optional, Tuple, Callable, Any 

9from PIL import Image 

10import time 

11import numpy as np 

12 

13from pyWebLayout.concrete.functional import LinkText, ButtonText 

14from pyWebLayout.concrete.page import Page 

15 

16 

17class InteractionHandler: 

18 """ 

19 Manages the press-release lifecycle for interactive elements. 

20 

21 This class handles the timing and state management needed to show 

22 visual feedback when buttons or links are clicked. It can generate 

23 multiple rendered frames showing the pressed and released states. 

24 

25 Usage patterns: 

26 

27 Pattern A - Simple one-shot with automatic frames: 

28 handler = InteractionHandler(page) 

29 frames = handler.execute_with_feedback(button_element, point) 

30 # Returns: [pressed_frame, released_frame] 

31 # Show frames in sequence with brief delay 

32 

33 Pattern B - Manual state management for custom event loops: 

34 handler = InteractionHandler(page) 

35 handler.set_pressed_state(button_element, True) 

36 pressed_frame = handler.render_current_state() 

37 # ... show frame, wait, execute action ... 

38 handler.set_pressed_state(button_element, False) 

39 released_frame = handler.render_current_state() 

40 """ 

41 

42 def __init__(self, page: Page, press_duration_ms: int = 150): 

43 """ 

44 Initialize the interaction handler. 

45 

46 Args: 

47 page: The Page object containing the interactive elements 

48 press_duration_ms: How long to show the pressed state (default: 150ms) 

49 """ 

50 self._page = page 

51 self._press_duration_ms = press_duration_ms 

52 

53 def set_pressed_state(self, element, pressed: bool): 

54 """ 

55 Set the pressed state of an interactive element. 

56 

57 Args: 

58 element: A LinkText or ButtonText object 

59 pressed: True to show pressed, False to show released 

60 """ 

61 if isinstance(element, (LinkText, ButtonText)): 

62 # Ensure element has page reference for dirty flag 

63 if not hasattr(element, '_page') or element._page is None: 

64 element.set_page(self._page) 

65 element.set_pressed(pressed) 

66 else: 

67 raise TypeError( 

68 f"Element must be LinkText or ButtonText, got {type(element)}") 

69 

70 def set_hovered_state(self, element, hovered: bool): 

71 """ 

72 Set the hovered state of an interactive element. 

73 

74 Args: 

75 element: A LinkText or ButtonText object 

76 hovered: True to show hovered, False for normal 

77 """ 

78 if isinstance(element, (LinkText, ButtonText)): 

79 # Ensure element has page reference for dirty flag 

80 if not hasattr(element, '_page') or element._page is None: 

81 element.set_page(self._page) 

82 element.set_hovered(hovered) 

83 else: 

84 raise TypeError( 

85 f"Element must be LinkText or ButtonText, got {type(element)}") 

86 

87 def render_current_state(self) -> Image.Image: 

88 """ 

89 Render the page with current element states. 

90 

91 Returns: 

92 PIL Image of the rendered page 

93 """ 

94 return self._page.render() 

95 

96 def execute_with_feedback( 

97 self, 

98 element, 

99 point: Optional[np.ndarray] = None, 

100 callback: Optional[Callable] = None) -> Tuple[Image.Image, Image.Image, Any]: 

101 """ 

102 Execute an interaction with visual feedback at each stage. 

103 

104 This is the high-level "all-in-one" method that: 

105 1. Sets pressed state and renders 

106 2. Waits for press_duration_ms 

107 3. Executes the element's callback (or provided callback) 

108 4. Sets released state and renders 

109 

110 Args: 

111 element: A LinkText or ButtonText object 

112 point: Optional point where interaction occurred 

113 callback: Optional custom callback (overrides element's callback) 

114 

115 Returns: 

116 Tuple of (pressed_frame, released_frame, callback_result) 

117 """ 

118 # Step 1: Render pressed state 

119 self.set_pressed_state(element, True) 

120 pressed_frame = self.render_current_state() 

121 

122 # Step 2: Wait for visual feedback duration 

123 time.sleep(self._press_duration_ms / 1000.0) 

124 

125 # Step 3: Execute callback 

126 callback_result = None 

127 if callback: 

128 callback_result = callback(point) if point is not None else callback() 

129 elif hasattr(element, 'interact'): 

130 callback_result = element.interact(point) 

131 

132 # Step 4: Render released state 

133 self.set_pressed_state(element, False) 

134 released_frame = self.render_current_state() 

135 

136 return pressed_frame, released_frame, callback_result 

137 

138 def execute_async_with_feedback( 

139 self, 

140 element, 

141 point: Optional[np.ndarray] = None) -> Tuple[Image.Image, Callable, Image.Image]: 

142 """ 

143 Execute an interaction with visual feedback, returning frames immediately 

144 without blocking. 

145 

146 This method returns the frames and a callback to execute later, allowing 

147 the caller to control when the action actually happens. 

148 

149 Args: 

150 element: A LinkText or ButtonText object 

151 point: Optional point where interaction occurred 

152 

153 Returns: 

154 Tuple of (pressed_frame, execute_callback, released_frame) 

155 where execute_callback is a function that will execute the interaction 

156 """ 

157 # Render pressed state 

158 self.set_pressed_state(element, True) 

159 pressed_frame = self.render_current_state() 

160 

161 # Create callback that will execute the interaction and reset state 

162 def execute_callback(): 

163 result = None 

164 if hasattr(element, 'interact'): 

165 result = element.interact(point) 

166 self.set_pressed_state(element, False) 

167 return result 

168 

169 # Pre-render the released state (element state is still pressed) 

170 # We'll return this frame but the caller controls when to show it 

171 self.set_pressed_state(element, False) 

172 released_frame = self.render_current_state() 

173 

174 # Reset back to pressed for consistency 

175 # (caller will call execute_callback which sets to False) 

176 self.set_pressed_state(element, True) 

177 

178 return pressed_frame, execute_callback, released_frame 

179 

180 

181class InteractionStateManager: 

182 """ 

183 Manages interaction states for multiple elements on a page. 

184 

185 Useful for applications that need to track hover/press states 

186 across many interactive elements simultaneously. 

187 """ 

188 

189 def __init__(self, page: Page): 

190 """ 

191 Initialize the state manager. 

192 

193 Args: 

194 page: The Page object containing interactive elements 

195 """ 

196 self._page = page 

197 self._hovered_element = None 

198 self._pressed_element = None 

199 

200 def update_hover(self, point: Tuple[int, int]) -> Optional[Image.Image]: 

201 """ 

202 Update hover state based on cursor position. 

203 

204 Queries the page to find what's under the cursor and updates 

205 hover states accordingly. 

206 

207 Args: 

208 point: Cursor position (x, y) 

209 

210 Returns: 

211 New rendered frame if hover state changed, None otherwise 

212 """ 

213 # Query what's at this point 

214 result = self._page.query_point(point) 

215 

216 if not result or not result.is_interactive: 

217 # Nothing interactive under cursor 

218 if self._hovered_element: 

219 # Clear previous hover 

220 if isinstance(self._hovered_element, (LinkText, ButtonText)): 

221 self._hovered_element.set_hovered(False) 

222 self._hovered_element = None 

223 return self._page.render() 

224 return None 

225 

226 # Something interactive is under cursor 

227 element = result.object 

228 if element != self._hovered_element: 

229 # Hover changed 

230 # Clear old hover 

231 if self._hovered_element and isinstance( 

232 self._hovered_element, (LinkText, ButtonText)): 

233 self._hovered_element.set_hovered(False) 

234 

235 # Set new hover 

236 if isinstance(element, (LinkText, ButtonText)): 

237 element.set_hovered(True) 

238 

239 self._hovered_element = element 

240 return self._page.render() 

241 

242 return None 

243 

244 def handle_mouse_down(self, point: Tuple[int, int]) -> Optional[Image.Image]: 

245 """ 

246 Handle mouse button press at a point. 

247 

248 Args: 

249 point: Click position (x, y) 

250 

251 Returns: 

252 New rendered frame showing pressed state, or None if nothing interactive 

253 """ 

254 result = self._page.query_point(point) 

255 

256 if not result or not result.is_interactive: 

257 return None 

258 

259 element = result.object 

260 if isinstance(element, (LinkText, ButtonText)): 

261 element.set_pressed(True) 

262 self._pressed_element = element 

263 return self._page.render() 

264 

265 return None 

266 

267 def handle_mouse_up( 

268 self, 

269 point: Tuple[int, 

270 int]) -> Tuple[Optional[Image.Image], 

271 Any]: 

272 """ 

273 Handle mouse button release at a point. 

274 

275 Args: 

276 point: Release position (x, y) 

277 

278 Returns: 

279 Tuple of (rendered_frame, callback_result) 

280 Frame shows released state, result is from executing the callback 

281 """ 

282 if not self._pressed_element: 

283 return None, None 

284 

285 # Execute the interaction 

286 callback_result = None 

287 if hasattr(self._pressed_element, 'interact'): 

288 callback_result = self._pressed_element.interact( 

289 np.array(point)) 

290 

291 # Release the pressed state 

292 if isinstance(self._pressed_element, (LinkText, ButtonText)): 

293 self._pressed_element.set_pressed(False) 

294 

295 self._pressed_element = None 

296 

297 return self._page.render(), callback_result 

298 

299 def reset(self): 

300 """Reset all interaction states.""" 

301 if self._hovered_element and isinstance( 

302 self._hovered_element, (LinkText, ButtonText)): 

303 self._hovered_element.set_hovered(False) 

304 

305 if self._pressed_element and isinstance( 

306 self._pressed_element, (LinkText, ButtonText)): 

307 self._pressed_element.set_pressed(False) 

308 

309 self._hovered_element = None 

310 self._pressed_element = None