diff --git a/src/midiexplorer/gui/__init__.py b/src/midiexplorer/gui/__init__.py index b1c8c58..acb810d 100644 --- a/src/midiexplorer/gui/__init__.py +++ b/src/midiexplorer/gui/__init__.py @@ -120,7 +120,6 @@ def init(): log_win_textbox = dpg.get_item_children('log_win', slot=midiexplorer.gui.helpers.constants.slots.Slots.MOST)[2] dpg.bind_item_font(log_win_textbox, 'mono_font') - dpg.bind_item_font('hist_data_table_headers', 'mono_font') dpg.bind_item_font('hist_data_table', 'mono_font') if DEBUG: diff --git a/src/midiexplorer/gui/helpers/probe.py b/src/midiexplorer/gui/helpers/probe.py index cd51297..aa16a29 100644 --- a/src/midiexplorer/gui/helpers/probe.py +++ b/src/midiexplorer/gui/helpers/probe.py @@ -25,12 +25,4 @@ def add(timestamp: Timestamp, source: str, data: mido.Message) -> None: logger.log_debug(f"Adding data from {source} to probe at {timestamp}: {data!r}") - # FIXME: data.time can also be 0 when using rtmidi time delta. How do we discriminate? Use another property in mido? - delta = None - if data.time and DEBUG: - delta = data.time - logger.log_debug("Timing: Using rtmidi time delta") - else: - logger.log_debug("Timing: Rtmidi time delta not available. Computing timestamp locally.") - midiexplorer.gui.windows.mon.data.update_gui_monitor(data) diff --git a/src/midiexplorer/gui/windows/conn.py b/src/midiexplorer/gui/windows/conn.py index 608d6dd..2b831ef 100644 --- a/src/midiexplorer/gui/windows/conn.py +++ b/src/midiexplorer/gui/windows/conn.py @@ -694,8 +694,9 @@ def handle_received_data(timestamp: Timestamp, source: str, dest: str, midi_data if probe_thru_user_data: # Handle soft-thru # logger.log(f"Probe thru has user data: {probe_thru_user_data}") logger.log_debug("Echoing MIDI data to probe thru") + thru_timestamp = Timestamp() probe_thru_user_data.port.send(midi_data) - hist.data.add(midi_data, "PROBE: Thru", probe_thru_user_data.port.name, timestamp) + hist.data.add(midi_data, "PROBE: Thru", probe_thru_user_data.port.name, thru_timestamp) add( timestamp=timestamp, source=source, diff --git a/src/midiexplorer/gui/windows/gen.py b/src/midiexplorer/gui/windows/gen.py index e60170a..d732e35 100644 --- a/src/midiexplorer/gui/windows/gen.py +++ b/src/midiexplorer/gui/windows/gen.py @@ -104,7 +104,7 @@ def decode(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: if warning is None: logger.log_debug(f"Raw message {app_data} decoded to: {decoded!r}.") - dpg.set_value('generator_decoded_message', repr(decoded)) + dpg.set_value('generator_decoded_message', decoded) dpg.enable_item('generator_send_button') dpg.set_item_user_data('generator_send_button', decoded) else: diff --git a/src/midiexplorer/gui/windows/hist/__init__.py b/src/midiexplorer/gui/windows/hist/__init__.py index b4643a0..1d9e059 100644 --- a/src/midiexplorer/gui/windows/hist/__init__.py +++ b/src/midiexplorer/gui/windows/hist/__init__.py @@ -13,23 +13,24 @@ from midiexplorer.__config__ import DEBUG from midiexplorer.gui.helpers.callbacks.debugging import enable as enable_dpg_cb_debugging -from midiexplorer.gui.windows.hist.data import init_details_table_data, clear_hist_data_table +from midiexplorer.gui.windows.hist.data import clear_hist_data_table def _add_table_columns(): - dpg.add_table_column(label="Source") - dpg.add_table_column(label="Destination") dpg.add_table_column(label="Timestamp (s)") dpg.add_table_column(label="Delta (ms)") + dpg.add_table_column(label="Source") + dpg.add_table_column(label="Destination") dpg.add_table_column(label="Raw Message (HEX)") if DEBUG: - dpg.add_table_column(label="Decoded Message") + dpg.add_table_column(label="Decoded\nMessage") dpg.add_table_column(label="Status") dpg.add_table_column(label="Channel") dpg.add_table_column(label="Data 1") dpg.add_table_column(label="Data 2") dpg.add_table_column(label="Select", width_fixed=True, width=0, no_header_width=True, no_header_label=True) + def create() -> None: """Creates the history window. @@ -48,8 +49,8 @@ def create() -> None: # History window # -------------------- with dpg.window( - tag='hist_win', label="History", + tag='hist_win', width=900, height=hist_win_height, no_close=True, @@ -59,48 +60,38 @@ def create() -> None: # ------------------- # History data table # ------------------- - hist_table_height = 470 - if DEBUG: - hist_table_height = 355 - dpg.add_child_window(tag='hist_table_container', height=hist_table_height, border=False) - - # Separate headers - # FIXME: workaround table scrolling not implemented upstream yet to have static headers - # dpg.add_child_window(tag='hist_det_headers', label="Details headers", height=5, border=False) - with dpg.table(parent='hist_table_container', - tag='hist_data_table_headers', - header_row=True, - freeze_rows=1, - policy=dpg.mvTable_SizingStretchSame): - _add_table_columns() + + # Buttons + with dpg.group(parent='hist_win', horizontal=True): + dpg.add_text("Order:") + dpg.add_radio_button(items=("Reversed", "Auto-Scroll"), label="Mode", tag='hist_data_table_mode', + default_value="Reversed", horizontal=True) + dpg.add_checkbox(label="Selection to Generator", tag='hist_data_to_gen', default_value=True) + dpg.add_button(label="Clear", callback=clear_hist_data_table) # TODO: Allow sorting - # TODO: Show/hide columns # TODO: timegraph? # Content details - hist_det_height = 420 - if DEBUG: - hist_det_height = 305 - dpg.add_child_window(parent='hist_table_container', tag='hist_det', label="Details", height=hist_det_height, border=False) - with dpg.table(parent='hist_det', - tag='hist_data_table', - header_row=False, # FIXME: True when table scrolling will be implemented upstream - freeze_rows=0, # FIXME: 1 when table scrolling will be implemented upstream - row_background=True, - borders_innerV=True, - policy=dpg.mvTable_SizingStretchSame, - # scrollY=True, # FIXME: Scroll the table instead of the window when available upstream - ): + with dpg.table( + tag='hist_data_table', + parent='hist_win', + header_row=True, + #clipper= True, + policy=dpg.mvTable_SizingStretchProp, + freeze_rows=1, + # sort_multi=True, + # sort_tristate=True, # TODO: implement + resizable=True, + reorderable=True, # TODO: TableSetupColumn()? + hideable=True, + # sortable=True, # TODO: TableGetSortSpecs()? + context_menu_in_body=True, + row_background=True, + borders_innerV=True, + scrollY=True, + ): _add_table_columns() - init_details_table_data() - - # Buttons - # FIXME: separated to not scroll with table child window until table scrolling is supported - dpg.add_child_window(parent='hist_table_container', tag='hist_btns', label="Buttons", border=False) - with dpg.group(parent='hist_btns', horizontal=True): - dpg.add_checkbox(tag='hist_data_table_autoscroll', label="Auto-Scroll", default_value=True) - dpg.add_button(label="Clear", callback=clear_hist_data_table) def toggle(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: @@ -122,4 +113,3 @@ def toggle(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: menu_item = 'menu_tools_history' if sender != menu_item: # Update menu checkmark when coming from the shortcut handler dpg.set_value(menu_item, not dpg.get_value(menu_item)) - diff --git a/src/midiexplorer/gui/windows/hist/data.py b/src/midiexplorer/gui/windows/hist/data.py index 859b603..eaf6e08 100644 --- a/src/midiexplorer/gui/windows/hist/data.py +++ b/src/midiexplorer/gui/windows/hist/data.py @@ -20,11 +20,12 @@ enable as enable_dpg_cb_debugging from midiexplorer.gui.helpers.constants.slots import Slots from midiexplorer.gui.helpers.convert import tooltip_conv +from midiexplorer.gui.helpers.logger import Logger from midiexplorer.gui.windows.mon import notation_modes from midiexplorer.midi.timestamp import Timestamp S2MS = 1000 # Seconds to milliseconds ratio -MAX_SIZE = 180 # Data table struggles with too many elements. +MAX_SIZE = 250 # Data table struggles with too many elements. ### # GLOBAL VARIABLES @@ -32,15 +33,7 @@ # FIXME: global variables should ideally be eliminated as they are a poor programming style ### hist_data_counter = 0 -selectables = [] - - -def init_details_table_data() -> None: - """Initial table data for reverse scrolling. - - """ - with dpg.table_row(parent='hist_data_table', label='hist_data_0'): - pass +selected = None def clear_hist_data_table( @@ -55,32 +48,33 @@ def clear_hist_data_table( :param user_data: argument is Optionally used to pass your own python data into the function. """ - global selectables, hist_data_counter + global hist_data_counter, selected if DEBUG: enable_dpg_cb_debugging(sender, app_data, user_data) - selectables.clear() hist_data_counter = 0 + selected = None dpg.delete_item('hist_data_table', children_only=True, slot=Slots.MOST) - init_details_table_data() -def add(data: mido.Message, source: str, destination: str, timestamp: Timestamp, delta=None) -> None: +def add(data: mido.Message, source: str, destination: str, timestamp: Timestamp) -> None: """Adds data to the history table. :param data: Midi message :param source: Source name :param destination: Destination name :param timestamp: Message data timestamp - :param delta: Time delta since previous message in seconds """ - global hist_data_counter, selectables + global hist_data_counter, selected - # TODO: insert new data at the top of the table - previous_data = hist_data_counter + logger = Logger() + + # Unselect + if selected is not None: + dpg.set_value(selected, False) # Deselect all items upon receiving new data # Flush data after a certain amount to avoid memory leak issues # TODO: add setting @@ -88,32 +82,48 @@ def add(data: mido.Message, source: str, destination: str, timestamp: Timestamp, # TODO: serialize chunk somewhere to allow unlimited scrolling when implemented clear_hist_data_table() - hist_data_counter += 1 - chan_val, data0_name, data0_val, data0_dec, data1_name, data1_val, data1_dec = decode(data) - if delta is None: + # FIXME: data.time can also be 0 when using rtmidi time delta. How do we discriminate? Use another property in mido? + if data.time and DEBUG: + logger.log_debug("Timing: Using rtmidi time delta") + delta = data.time + else: + logger.log_debug("Timing: Rtmidi time delta not available. Computing timestamp locally.") + # FIXME: this delta is not relative to the same message train but to every handled messages! delta = timestamp.delta - with dpg.table_row(parent='hist_data_table', label=f'hist_data_{hist_data_counter}', - before=f'hist_data_{previous_data}'): + # Reversed order + before = 0 + if dpg.get_value('hist_data_table_mode') == "Reversed" and hist_data_counter != 0: + before = f'hist_data_{hist_data_counter - 1}' - # Source - dpg.add_text(source) - - # Destination - dpg.add_text(destination) + with dpg.table_row( + tag=f'hist_data_{hist_data_counter}', + parent='hist_data_table', + before=before, + ): # Timestamp (s) - dpg.add_text(f"{timestamp.value:n}") + dpg.add_text(f"{timestamp.value:12.4f}") with dpg.tooltip(dpg.last_item()): dpg.add_text(f"{timestamp.value}") # Delta (ms) - dpg.add_text(f"{delta * S2MS:n}") + dpg.add_text(f"{delta * S2MS:12.4f}") with dpg.tooltip(dpg.last_item()): dpg.add_text(f"{delta * S2MS}") + # Source + dpg.add_text(source) + with dpg.tooltip(dpg.last_item()): + dpg.add_text(source) + + # Destination + dpg.add_text(destination) + with dpg.tooltip(dpg.last_item()): + dpg.add_text(destination) + # Raw message raw_label = data.hex() dpg.add_text(raw_label) @@ -121,7 +131,7 @@ def add(data: mido.Message, source: str, destination: str, timestamp: Timestamp, # Decoded message if DEBUG: - dec_label = repr(data) + dec_label = str(data) dpg.add_text(dec_label) with dpg.tooltip(dpg.last_item()): dpg.add_text(dec_label) @@ -129,7 +139,7 @@ def add(data: mido.Message, source: str, destination: str, timestamp: Timestamp, # Status status_byte = midiexplorer.midi.mido2standard.get_status_by_type( data.type - ) + ) stat_label = midi_const.STATUS_BYTES[status_byte] dpg.add_text(stat_label) if hasattr(data, 'channel'): @@ -142,22 +152,24 @@ def add(data: mido.Message, source: str, destination: str, timestamp: Timestamp, chan_label = "Global" if chan_val is not None: chan_label = chan_val + 1 # Human-readable format - dpg.add_text(chan_label) + dpg.add_text(f'{chan_label: >2}') tooltip_conv(chan_label, chan_val, hlen=1, dlen=2, blen=4) # Helper function equivalent to str() but avoids displaying 'None'. xstr: Callable[[Any], str] = lambda s: '' if s is None else str(s) + # Data 1 if data0_dec: dpg.add_text(str(data0_dec)) else: - dpg.add_text(xstr(data0_val)) + dpg.add_text(f'{xstr(data1_val): >3}') prefix0 = "" if data0_name: prefix0 = data0_name + ": " tooltip_conv(prefix0 + xstr(data0_dec if data0_dec else data0_val), data0_val, blen=7) - dpg.add_text(xstr(data1_val)) + # Data 2 + dpg.add_text(f'{xstr(data1_val): >3}') prefix1 = "" if data1_name: prefix1 = data1_name + ": " @@ -165,20 +177,16 @@ def add(data: mido.Message, source: str, destination: str, timestamp: Timestamp, # Selectable target = f'selectable_{hist_data_counter}' - dpg.add_selectable(span_columns=True, tag=target, callback=_selection) - selectables.append(target) + dpg.add_selectable(span_columns=True, tag=target, callback=_selection, user_data=data) + + hist_data_counter += 1 # TODO: per message type color coding # dpg.highlight_table_row(table_id, i, [255, 0, 0, 100]) # Autoscroll - if dpg.get_value('hist_data_table_autoscroll'): - dpg.set_y_scroll('hist_det', -1.0) - - # Single selection - # FIXME: add a data structure tracking selected items to only deselect the one(s) - for item in selectables: - dpg.set_value(item, False) # Deselect all items upon receiving new data + if dpg.get_value('hist_data_table_mode') == "Auto-Scroll": + dpg.set_y_scroll('hist_data_table', -1.0) def _selection(sender, app_data, user_data): @@ -192,25 +200,26 @@ def _selection(sender, app_data, user_data): :param user_data: argument is Optionally used to pass your own python data into the function. """ - global selectables + global selected if DEBUG: enable_dpg_cb_debugging(sender, app_data, user_data) - # FIXME: add a data structure tracking selected items to only deselect the one(s) - for item in selectables: - if item != sender: - dpg.set_value(item, False) - - raw_message = dpg.get_value( - dpg.get_item_children( - dpg.get_item_parent(sender), - slot=Slots.MOST - )[6] - ) - message = mido.Message.from_hex(raw_message) + # Single selection + if selected is not None: + dpg.set_value(selected, False) + selected = sender + + message = user_data midiexplorer.gui.windows.mon.data.update_gui_monitor(message, static=True) + # TODO: prevent overwriting user input + if dpg.get_value('hist_data_to_gen'): + dpg.set_value('generator_raw_message', message.hex()) + dpg.set_value('generator_decoded_message', message) + dpg.set_item_user_data('generator_send_button', message) + dpg.enable_item('generator_send_button') + def decode(data: mido.Message) -> tuple[int, int, int, int, int, int, int]: """Decodes the data.