- Fixed DataTable row selection and event handling - Added explicit column keys to prevent auto-generated keys - Implemented row-to-flow mapping for reliable selection tracking - Converted left metrics panel to horizontal top bar - Fixed all missing FlowStats/EnhancedAnalysisData attributes - Created comprehensive Textual API documentation in Documentation/textual/ - Added validation checklist to prevent future API mismatches - Preserved cursor position during data refreshes - Fixed RowKey type handling and event names The TUI now properly handles flow selection, displays metrics in a compact top bar, and correctly correlates selected rows with the details pane.
551 lines
23 KiB
Python
551 lines
23 KiB
Python
"""
|
|
Flow Analysis View - Visual flow overview with protocol detection
|
|
Focuses on understanding communication patterns and flow characteristics
|
|
"""
|
|
|
|
import curses
|
|
from typing import TYPE_CHECKING, List, Optional, Tuple
|
|
from ...models import FlowStats
|
|
|
|
if TYPE_CHECKING:
|
|
from ...analysis.core import EthernetAnalyzer
|
|
|
|
|
|
class FlowAnalysisView:
|
|
"""
|
|
Flow Analysis View - F1
|
|
|
|
Primary view for understanding network flows:
|
|
- Flow overview with visual indicators
|
|
- Protocol detection and classification
|
|
- Traffic patterns and volume analysis
|
|
- Enhanced decoder availability
|
|
"""
|
|
|
|
def __init__(self, analyzer: 'EthernetAnalyzer'):
|
|
self.analyzer = analyzer
|
|
self.selected_flow = 0
|
|
self.scroll_offset = 0
|
|
self.show_frame_types = True
|
|
self.column_widths = {}
|
|
|
|
def draw(self, stdscr, selected_flow_key: Optional[str]):
|
|
"""Draw the Flow Analysis view"""
|
|
height, width = stdscr.getmaxyx()
|
|
start_y = 3 # After header
|
|
max_height = height - 2 # Reserve for status bar
|
|
|
|
flows_list = self._get_flows_list()
|
|
|
|
if not flows_list:
|
|
stdscr.addstr(start_y + 2, 4, "No network flows detected", curses.A_DIM)
|
|
stdscr.addstr(start_y + 3, 4, "Waiting for packets...", curses.A_DIM)
|
|
return
|
|
|
|
# Flow summary header
|
|
summary = self.analyzer.get_summary()
|
|
summary_text = (f"Flows: {summary['unique_flows']} | "
|
|
f"Packets: {summary['total_packets']} | "
|
|
f"Endpoints: {summary['unique_ips']}")
|
|
|
|
if self.analyzer.is_live:
|
|
rt_summary = self.analyzer.statistics_engine.get_realtime_summary()
|
|
summary_text += f" | Outliers: {rt_summary.get('total_outliers', 0)}"
|
|
|
|
stdscr.addstr(start_y, 4, summary_text, curses.A_BOLD)
|
|
|
|
# Flow analysis area
|
|
flow_start_y = start_y + 2
|
|
self._draw_flow_overview(stdscr, flow_start_y, width, max_height - flow_start_y, flows_list)
|
|
|
|
def _draw_flow_overview(self, stdscr, start_y: int, width: int, max_height: int, flows_list: List[FlowStats]):
|
|
"""Draw comprehensive flow overview"""
|
|
|
|
# Header
|
|
stdscr.addstr(start_y, 4, "FLOW ANALYSIS", curses.A_BOLD | curses.A_UNDERLINE)
|
|
current_y = start_y + 2
|
|
|
|
# Calculate dynamic column widths based on available space
|
|
self.column_widths = self._calculate_column_widths(width)
|
|
|
|
# Column headers with dynamic widths
|
|
headers = self._format_headers()
|
|
stdscr.addstr(current_y, 4, headers, curses.A_UNDERLINE)
|
|
current_y += 1
|
|
|
|
# Calculate visible range
|
|
visible_flows = max_height - (current_y - start_y) - 2
|
|
start_idx = self.scroll_offset
|
|
end_idx = min(start_idx + visible_flows, len(flows_list))
|
|
|
|
# Draw flows with sub-rows for each extended protocol/frame type variation
|
|
display_row = 0
|
|
for i in range(start_idx, end_idx):
|
|
flow = flows_list[i]
|
|
|
|
# Flow selection
|
|
is_selected = (i == self.selected_flow)
|
|
|
|
# Get distinct extended protocol/frame type combinations
|
|
protocol_frame_combinations = self._get_protocol_frame_combinations(flow)
|
|
|
|
# Main flow line (summary)
|
|
attr = curses.A_REVERSE if is_selected else curses.A_BOLD
|
|
flow_line = self._format_flow_summary_line(i + 1, flow)
|
|
stdscr.addstr(current_y + display_row, 4, flow_line, attr)
|
|
|
|
# Enhanced indicator
|
|
if flow.enhanced_analysis.decoder_type != "Standard":
|
|
stdscr.addstr(current_y + display_row, 2, "●", curses.A_BOLD | curses.color_pair(1))
|
|
|
|
display_row += 1
|
|
|
|
# Sub-rows for each protocol/frame type combination
|
|
for j, (extended_proto, frame_type, count, percentage) in enumerate(protocol_frame_combinations):
|
|
if current_y + display_row >= current_y + visible_flows:
|
|
break
|
|
|
|
sub_attr = curses.A_REVERSE if (is_selected and j == 0) else curses.A_DIM
|
|
sub_line = self._format_protocol_frame_line(flow, extended_proto, frame_type, count, percentage)
|
|
stdscr.addstr(current_y + display_row, 4, sub_line, sub_attr)
|
|
display_row += 1
|
|
|
|
# Stop if we've filled the visible area
|
|
if current_y + display_row >= current_y + visible_flows:
|
|
break
|
|
|
|
# Scroll indicators
|
|
if start_idx > 0:
|
|
stdscr.addstr(current_y, width - 10, "↑ More", curses.A_DIM)
|
|
if end_idx < len(flows_list):
|
|
stdscr.addstr(current_y + visible_flows - 1, width - 10, "↓ More", curses.A_DIM)
|
|
|
|
def _calculate_column_widths(self, terminal_width: int) -> dict:
|
|
"""Calculate dynamic column widths based on available terminal width"""
|
|
# Reserve space for margins and prevent line wrapping
|
|
# 4 chars left margin + 4 chars right margin + 8 safety margin to prevent wrapping
|
|
available_width = terminal_width - 16
|
|
|
|
# Fixed minimum widths for critical columns
|
|
min_widths = {
|
|
'flow_num': 3, # " #"
|
|
'source': 15, # Compact IP:port
|
|
'proto': 4, # "UDP", "TCP"
|
|
'destination': 15, # Compact IP:port
|
|
'extended': 6, # "CH10", "PTP"
|
|
'frame_type': 8, # Compact frame type
|
|
'pkts': 6, # Right-aligned numbers
|
|
'volume': 8, # Right-aligned with units
|
|
'timing': 8, # Right-aligned with units
|
|
'quality': 8 # Right-aligned percentages
|
|
}
|
|
|
|
# Calculate total minimum width needed
|
|
min_total = sum(min_widths.values())
|
|
|
|
# If we have extra space, distribute it proportionally
|
|
if available_width > min_total:
|
|
extra_space = available_width - min_total
|
|
|
|
# Distribute extra space to text columns (source, destination, extended, frame_type)
|
|
expandable_columns = ['source', 'destination', 'extended', 'frame_type']
|
|
extra_per_column = extra_space // len(expandable_columns)
|
|
|
|
widths = min_widths.copy()
|
|
for col in expandable_columns:
|
|
widths[col] += extra_per_column
|
|
|
|
# Give any remaining space to source and destination
|
|
remaining = extra_space % len(expandable_columns)
|
|
if remaining > 0:
|
|
widths['source'] += remaining // 2
|
|
widths['destination'] += remaining // 2
|
|
if remaining % 2:
|
|
widths['source'] += 1
|
|
else:
|
|
# Use minimum widths if terminal is too narrow
|
|
widths = min_widths
|
|
|
|
return widths
|
|
|
|
def _format_headers(self) -> str:
|
|
"""Format column headers using dynamic widths"""
|
|
cw = self.column_widths
|
|
return (
|
|
f"{'#':>{cw['flow_num']-1}} "
|
|
f"{'Source':<{cw['source']}} "
|
|
f"{'Proto':<{cw['proto']}} "
|
|
f"{'Destination':<{cw['destination']}} "
|
|
f"{'Extended':<{cw['extended']}} "
|
|
f"{'Frame Type':<{cw['frame_type']}} "
|
|
f"{'Pkts':>{cw['pkts']}} "
|
|
f"{'Volume':>{cw['volume']}} "
|
|
f"{'Timing':>{cw['timing']}} "
|
|
f"{'Quality':>{cw['quality']}}"
|
|
)
|
|
|
|
def _get_protocol_frame_combinations(self, flow: FlowStats) -> List[Tuple[str, str, int, float]]:
|
|
"""Get distinct extended protocol/frame type combinations for a flow"""
|
|
combinations = []
|
|
total_packets = flow.frame_count
|
|
|
|
# Group frame types by extended protocol
|
|
protocol_frames = {}
|
|
|
|
if flow.frame_types:
|
|
for frame_type, ft_stats in flow.frame_types.items():
|
|
# Determine extended protocol for this frame type
|
|
extended_proto = self._get_extended_protocol_for_frame(flow, frame_type)
|
|
|
|
if extended_proto not in protocol_frames:
|
|
protocol_frames[extended_proto] = []
|
|
|
|
protocol_frames[extended_proto].append((frame_type, ft_stats.count))
|
|
else:
|
|
# No frame types, just show the flow-level extended protocol
|
|
extended_proto = self._get_extended_protocol(flow)
|
|
protocol_frames[extended_proto] = [("General", total_packets)]
|
|
|
|
# Convert to list of tuples with percentages
|
|
for extended_proto, frame_list in protocol_frames.items():
|
|
for frame_type, count in frame_list:
|
|
percentage = (count / total_packets * 100) if total_packets > 0 else 0
|
|
combinations.append((extended_proto, frame_type, count, percentage))
|
|
|
|
# Sort by count (descending)
|
|
combinations.sort(key=lambda x: x[2], reverse=True)
|
|
return combinations
|
|
|
|
def _get_extended_protocol_for_frame(self, flow: FlowStats, frame_type: str) -> str:
|
|
"""Get extended protocol for a specific frame type"""
|
|
if frame_type.startswith('CH10') or frame_type == 'TMATS':
|
|
return 'CH10'
|
|
elif frame_type.startswith('PTP'):
|
|
return 'PTP'
|
|
elif frame_type == 'IENA':
|
|
return 'IENA'
|
|
elif frame_type == 'NTP':
|
|
return 'NTP'
|
|
else:
|
|
# Fallback to flow-level extended protocol
|
|
return self._get_extended_protocol(flow)
|
|
|
|
def _format_flow_summary_line(self, flow_num: int, flow: FlowStats) -> str:
|
|
"""Format the main flow summary line"""
|
|
# Source with port (left-aligned)
|
|
source = f"{flow.src_ip}:{flow.src_port}"
|
|
max_source_len = self.column_widths.get('source', 24) - 2
|
|
if len(source) > max_source_len:
|
|
ip_space = max_source_len - len(f":{flow.src_port}") - 1 # -1 for ellipsis
|
|
source = f"{flow.src_ip[:ip_space]}…:{flow.src_port}"
|
|
|
|
# Transport protocol
|
|
protocol = flow.transport_protocol
|
|
|
|
# Destination with port (left-aligned)
|
|
destination = f"{flow.dst_ip}:{flow.dst_port}"
|
|
max_dest_len = self.column_widths.get('destination', 24) - 2
|
|
if len(destination) > max_dest_len:
|
|
ip_space = max_dest_len - len(f":{flow.dst_port}") - 1 # -1 for ellipsis
|
|
destination = f"{flow.dst_ip[:ip_space]}…:{flow.dst_port}"
|
|
|
|
# Summary info instead of specific extended/frame
|
|
extended_summary = f"{len(self._get_protocol_frame_combinations(flow))} types"
|
|
frame_summary = "Mixed" if len(flow.frame_types) > 1 else "Single"
|
|
|
|
# Packet count
|
|
pkt_count = f"{flow.frame_count}"
|
|
|
|
# Volume with units
|
|
volume = self._format_bytes(flow.total_bytes)
|
|
|
|
# Timing quality
|
|
if flow.avg_inter_arrival > 0:
|
|
timing_ms = flow.avg_inter_arrival * 1000
|
|
if timing_ms >= 1000:
|
|
timing = f"{timing_ms/1000:.1f}s"
|
|
else:
|
|
timing = f"{timing_ms:.1f}ms"
|
|
else:
|
|
timing = "N/A"
|
|
|
|
# Quality score
|
|
if flow.enhanced_analysis.decoder_type != "Standard":
|
|
if flow.enhanced_analysis.avg_frame_quality > 0:
|
|
quality = f"{flow.enhanced_analysis.avg_frame_quality:.0f}%"
|
|
else:
|
|
quality = "Enhanced"
|
|
else:
|
|
# Check for outliers
|
|
outlier_pct = len(flow.outlier_frames) / flow.frame_count * 100 if flow.frame_count > 0 else 0
|
|
if outlier_pct > 5:
|
|
quality = f"{outlier_pct:.0f}% Out"
|
|
else:
|
|
quality = "Normal"
|
|
|
|
cw = self.column_widths
|
|
return (f"{flow_num:>{cw['flow_num']-1}} "
|
|
f"{source:<{cw['source']}} "
|
|
f"{protocol:<{cw['proto']}} "
|
|
f"{destination:<{cw['destination']}} "
|
|
f"{extended_summary:<{cw['extended']}} "
|
|
f"{frame_summary:<{cw['frame_type']}} "
|
|
f"{pkt_count:>{cw['pkts']}} "
|
|
f"{volume:>{cw['volume']}} "
|
|
f"{timing:>{cw['timing']}} "
|
|
f"{quality:>{cw['quality']}}")
|
|
|
|
def _format_protocol_frame_line(self, flow: FlowStats, extended_proto: str, frame_type: str, count: int, percentage: float) -> str:
|
|
"""Format a sub-row line for a specific protocol/frame type combination"""
|
|
# Empty source/protocol/destination for sub-rows
|
|
source = ""
|
|
protocol = ""
|
|
destination = ""
|
|
|
|
# Extended protocol and frame type
|
|
extended = extended_proto if extended_proto != '-' else ""
|
|
frame = frame_type
|
|
|
|
# Packet count for this combination
|
|
pkt_count = f"{count}"
|
|
|
|
# Volume calculation (approximate based on percentage)
|
|
volume_bytes = int(flow.total_bytes * (percentage / 100))
|
|
volume = self._format_bytes(volume_bytes)
|
|
|
|
# Timing for this frame type if available
|
|
if frame_type in flow.frame_types and flow.frame_types[frame_type].avg_inter_arrival > 0:
|
|
timing_ms = flow.frame_types[frame_type].avg_inter_arrival * 1000
|
|
if timing_ms >= 1000:
|
|
timing = f"{timing_ms/1000:.1f}s" # Convert to seconds for large values
|
|
else:
|
|
timing = f"{timing_ms:.1f}ms"
|
|
else:
|
|
timing = "-"
|
|
|
|
# Percentage as quality indicator
|
|
quality = f"{percentage:.1f}%"
|
|
|
|
cw = self.column_widths
|
|
indent = " " * cw['flow_num'] # Match flow_num space allocation
|
|
return (f"{indent}"
|
|
f"{source:<{cw['source']}} "
|
|
f"{protocol:<{cw['proto']}} "
|
|
f"{destination:<{cw['destination']}} "
|
|
f"{extended:<{cw['extended']}} "
|
|
f"{frame:<{cw['frame_type']}} "
|
|
f"{pkt_count:>{cw['pkts']}} "
|
|
f"{volume:>{cw['volume']}} "
|
|
f"{timing:>{cw['timing']}} "
|
|
f"{quality:>{cw['quality']}}")
|
|
|
|
def _format_flow_line(self, flow_num: int, flow: FlowStats) -> str:
|
|
"""Format a single flow line with comprehensive information"""
|
|
|
|
# Source with port (left-aligned)
|
|
source = f"{flow.src_ip}:{flow.src_port}"
|
|
max_source_len = self.column_widths.get('source', 24) - 2
|
|
if len(source) > max_source_len:
|
|
ip_space = max_source_len - len(f":{flow.src_port}") - 1 # -1 for ellipsis
|
|
source = f"{flow.src_ip[:ip_space]}…:{flow.src_port}"
|
|
|
|
# Transport protocol (TCP, UDP, ICMP, IGMP, etc.)
|
|
protocol = flow.transport_protocol
|
|
|
|
# Destination with port (left-aligned)
|
|
destination = f"{flow.dst_ip}:{flow.dst_port}"
|
|
max_dest_len = self.column_widths.get('destination', 24) - 2
|
|
if len(destination) > max_dest_len:
|
|
ip_space = max_dest_len - len(f":{flow.dst_port}") - 1 # -1 for ellipsis
|
|
destination = f"{flow.dst_ip[:ip_space]}…:{flow.dst_port}"
|
|
|
|
# Extended protocol (Chapter 10, PTP, IENA, etc.)
|
|
extended_protocol = self._get_extended_protocol(flow)
|
|
|
|
# Frame type (most common frame type in this flow)
|
|
frame_type = self._get_primary_frame_type(flow)
|
|
|
|
# Packet count
|
|
pkt_count = f"{flow.frame_count}"
|
|
|
|
# Volume with units
|
|
volume = self._format_bytes(flow.total_bytes)
|
|
|
|
# Timing quality
|
|
if flow.avg_inter_arrival > 0:
|
|
timing_ms = flow.avg_inter_arrival * 1000
|
|
if timing_ms >= 1000:
|
|
timing = f"{timing_ms/1000:.1f}s"
|
|
else:
|
|
timing = f"{timing_ms:.1f}ms"
|
|
else:
|
|
timing = "N/A"
|
|
|
|
# Quality score
|
|
if flow.enhanced_analysis.decoder_type != "Standard":
|
|
if flow.enhanced_analysis.avg_frame_quality > 0:
|
|
quality = f"{flow.enhanced_analysis.avg_frame_quality:.0f}%"
|
|
else:
|
|
quality = "Enhanced"
|
|
else:
|
|
# Check for outliers
|
|
outlier_pct = len(flow.outlier_frames) / flow.frame_count * 100 if flow.frame_count > 0 else 0
|
|
if outlier_pct > 5:
|
|
quality = f"{outlier_pct:.0f}% Out"
|
|
else:
|
|
quality = "Normal"
|
|
|
|
return (f"{flow_num:>2} "
|
|
f"{source:20} "
|
|
f"{protocol:6} "
|
|
f"{destination:20} "
|
|
f"{extended_protocol:10} "
|
|
f"{frame_type:12} "
|
|
f"{pkt_count:>6} "
|
|
f"{volume:>8} "
|
|
f"{timing:>8} "
|
|
f"{quality:>8}")
|
|
|
|
def _draw_frame_types_compact(self, stdscr, y: int, width: int, flow: FlowStats):
|
|
"""Draw compact frame type breakdown for selected flow"""
|
|
frame_types = sorted(flow.frame_types.items(), key=lambda x: x[1].count, reverse=True)
|
|
|
|
# Compact frame type display
|
|
type_summary = []
|
|
for frame_type, ft_stats in frame_types[:4]: # Show top 4
|
|
type_summary.append(f"{frame_type}({ft_stats.count})")
|
|
|
|
if len(frame_types) > 4:
|
|
type_summary.append(f"+{len(frame_types)-4} more")
|
|
|
|
frame_line = f" └─ Frame Types: {', '.join(type_summary)}"
|
|
stdscr.addstr(y, 4, frame_line[:width-8], curses.A_DIM)
|
|
|
|
def _get_primary_protocol(self, flow: FlowStats) -> str:
|
|
"""Get the most relevant protocol for display"""
|
|
# Prioritize enhanced protocols
|
|
if flow.detected_protocol_types:
|
|
enhanced_protocols = {'CHAPTER10', 'PTP', 'IENA', 'CH10'}
|
|
found_enhanced = flow.detected_protocol_types & enhanced_protocols
|
|
if found_enhanced:
|
|
return list(found_enhanced)[0]
|
|
|
|
# Use first detected protocol
|
|
return list(flow.detected_protocol_types)[0]
|
|
|
|
# Fallback to transport protocol
|
|
return flow.transport_protocol
|
|
|
|
def _get_extended_protocol(self, flow: FlowStats) -> str:
|
|
"""Get extended protocol (Chapter 10, PTP, IENA, etc.)"""
|
|
if flow.detected_protocol_types:
|
|
# Look for specialized protocols
|
|
enhanced_protocols = {'CHAPTER10', 'CH10', 'PTP', 'IENA'}
|
|
found_enhanced = flow.detected_protocol_types & enhanced_protocols
|
|
if found_enhanced:
|
|
protocol = list(found_enhanced)[0]
|
|
# Simplify display names
|
|
if protocol in ['CHAPTER10', 'CH10']:
|
|
return 'CH10'
|
|
return protocol
|
|
|
|
# Check for other common protocols
|
|
if flow.detected_protocol_types and 'NTP' in flow.detected_protocol_types:
|
|
return 'NTP'
|
|
|
|
return '-'
|
|
|
|
def _get_primary_frame_type(self, flow: FlowStats) -> str:
|
|
"""Get the most common frame type in this flow"""
|
|
if not flow.frame_types:
|
|
return '-'
|
|
|
|
# Find the frame type with the most packets
|
|
most_common = max(flow.frame_types.items(), key=lambda x: x[1].count)
|
|
frame_type = most_common[0]
|
|
|
|
# Simplify frame type names for display
|
|
if frame_type == 'CH10-Data':
|
|
return 'CH10-Data'
|
|
elif frame_type == 'TMATS':
|
|
return 'TMATS'
|
|
elif frame_type.startswith('PTP-'):
|
|
return frame_type.replace('PTP-', 'PTP ')[:11] # PTP Sync, PTP Signal
|
|
elif frame_type == 'UDP':
|
|
return 'UDP'
|
|
elif frame_type == 'IGMP':
|
|
return 'IGMP'
|
|
else:
|
|
return frame_type[:11] # Truncate to fit column
|
|
|
|
def _format_bytes(self, bytes_count: int) -> str:
|
|
"""Format byte count with appropriate units"""
|
|
if bytes_count >= 1_000_000_000:
|
|
return f"{bytes_count / 1_000_000_000:.1f}GB"
|
|
elif bytes_count >= 1_000_000:
|
|
return f"{bytes_count / 1_000_000:.1f}MB"
|
|
elif bytes_count >= 1_000:
|
|
return f"{bytes_count / 1_000:.1f}KB"
|
|
else:
|
|
return f"{bytes_count}B"
|
|
|
|
def _get_flows_list(self) -> List[FlowStats]:
|
|
"""Get flows sorted by importance for flow analysis"""
|
|
flows_list = list(self.analyzer.flows.values())
|
|
|
|
# Sort by: Enhanced protocols first, then outliers, then packet count
|
|
flows_list.sort(key=lambda x: (
|
|
x.enhanced_analysis.decoder_type != "Standard",
|
|
len(x.outlier_frames),
|
|
x.frame_count
|
|
), reverse=True)
|
|
|
|
return flows_list
|
|
|
|
def handle_input(self, key: int, flows_list: List[FlowStats]) -> str:
|
|
"""Handle input for Flow Analysis view"""
|
|
if key == curses.KEY_UP:
|
|
self.selected_flow = max(0, self.selected_flow - 1)
|
|
self._adjust_scroll()
|
|
return 'selection_change'
|
|
elif key == curses.KEY_DOWN:
|
|
self.selected_flow = min(len(flows_list) - 1, self.selected_flow + 1)
|
|
self._adjust_scroll()
|
|
return 'selection_change'
|
|
elif key == curses.KEY_PPAGE: # Page Up
|
|
self.selected_flow = max(0, self.selected_flow - 10)
|
|
self._adjust_scroll()
|
|
return 'selection_change'
|
|
elif key == curses.KEY_NPAGE: # Page Down
|
|
self.selected_flow = min(len(flows_list) - 1, self.selected_flow + 10)
|
|
self._adjust_scroll()
|
|
return 'selection_change'
|
|
elif key == ord('\n') or key == curses.KEY_ENTER:
|
|
# Select flow for detailed analysis
|
|
if flows_list and self.selected_flow < len(flows_list):
|
|
selected_flow = flows_list[self.selected_flow]
|
|
# Signal flow selection for other views
|
|
return 'flow_selected'
|
|
elif key == ord('v') or key == ord('V'):
|
|
# Visualize selected flow
|
|
return 'visualize'
|
|
elif key == ord('d') or key == ord('D'):
|
|
# Switch to decoder view for selected flow
|
|
return 'decode_flow'
|
|
elif key == ord('t') or key == ord('T'):
|
|
# Toggle frame types display
|
|
self.show_frame_types = not self.show_frame_types
|
|
return 'toggle_frame_types'
|
|
|
|
return 'none'
|
|
|
|
def _adjust_scroll(self):
|
|
"""Adjust scroll position to keep selected item visible"""
|
|
# This would be implemented based on visible area calculations
|
|
pass
|
|
|
|
def get_selected_flow(self, flows_list: List[FlowStats]) -> Optional[FlowStats]:
|
|
"""Get currently selected flow"""
|
|
if flows_list and 0 <= self.selected_flow < len(flows_list):
|
|
return flows_list[self.selected_flow]
|
|
return None |