diff --git a/OSCRUI/app.py b/OSCRUI/app.py index a3e2891..d1a7d6b 100644 --- a/OSCRUI/app.py +++ b/OSCRUI/app.py @@ -19,7 +19,8 @@ class OSCRUI(): - from .datafunctions import init_parser, copy_summary_callback + + from .datafunctions import init_parser, copy_summary_callback, copy_analysis_callback from .datafunctions import analyze_log_callback, update_shown_columns_dmg, update_shown_columns_heal from .displayer import create_legend_item from .iofunctions import browse_path @@ -400,14 +401,11 @@ def setup_overview_frame(self): switch_style = { 'default': {'margin-left': '@margin', 'margin-right': '@margin'}, - 'DPS Bar': {'callback': lambda: self.switch_overview_tab(0), 'align': ACENTER, 'toggle': True}, - 'DPS Graph': {'callback': lambda: self.switch_overview_tab(1), 'align': ACENTER, 'toggle': False}, - 'Damage Graph': {'callback': lambda: self.switch_overview_tab(2), 'align': ACENTER, + 'DPS Bar': {'callback': lambda state: self.switch_overview_tab(0), 'align': ACENTER, 'toggle': True}, + 'DPS Graph': {'callback': lambda state: self.switch_overview_tab(1), 'align': ACENTER, 'toggle': False}, + 'Damage Graph': {'callback': lambda state: self.switch_overview_tab(2), 'align': ACENTER, 'toggle': False} } - # 'Copy Summary': {'callback': self.copy_summary_callback, 'align':ACENTER}, - # 'Upload Result': {'callback': self.upload_callback, 'align':ACENTER}, - # } switcher, buttons = self.create_button_series(switch_frame, switch_style, 'tab_button', ret=True) switcher.setContentsMargins(0, self.theme['defaults']['margin'], 0, 0) switch_frame.setLayout(switcher) @@ -439,9 +437,9 @@ def setup_analysis_frame(self): layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - - switch_frame = self.create_frame(a_frame, 'frame') - layout.addWidget(switch_frame, alignment=ACENTER) + switch_layout = QGridLayout() + switch_layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(switch_layout) a_tabber = QTabWidget(a_frame) a_tabber.setStyleSheet(self.get_style_class('QTabWidget', 'tabber')) @@ -453,19 +451,38 @@ def setup_analysis_frame(self): self.widgets.analysis_tabber = a_tabber layout.addWidget(a_tabber) + switch_layout.setColumnStretch(0, 1) + switch_frame = self.create_frame(a_frame, 'frame') + switch_layout.addWidget(switch_frame, 0, 1, alignment=ACENTER) + switch_layout.setColumnStretch(1, 1) + switch_style = { 'default': {'margin-left': '@margin', 'margin-right': '@margin'}, - 'Damage Out': {'callback': lambda: self.switch_analysis_tab(0), 'align': ACENTER, 'toggle': True}, - 'Damage Taken': {'callback': lambda: self.switch_analysis_tab(1), 'align': ACENTER, + 'Damage Out': {'callback': lambda state: self.switch_analysis_tab(0), 'align': ACENTER, + 'toggle': True}, + 'Damage Taken': {'callback': lambda state: self.switch_analysis_tab(1), 'align': ACENTER, 'toggle': False}, - 'Heals Out': {'callback': lambda: self.switch_analysis_tab(2), 'align': ACENTER, 'toggle': False}, - 'Heals In': {'callback': lambda: self.switch_analysis_tab(3), 'align': ACENTER, 'toggle': False} + 'Heals Out': {'callback': lambda state: self.switch_analysis_tab(2), 'align': ACENTER, + 'toggle': False}, + 'Heals In': {'callback': lambda state: self.switch_analysis_tab(3), 'align': ACENTER, + 'toggle': False} } switcher, buttons = self.create_button_series(switch_frame, switch_style, 'tab_button', ret=True) - # buttons[0].setEnabled(False) switcher.setContentsMargins(0, self.theme['defaults']['margin'], 0, 0) - self.widgets.analysis_menu_buttons = buttons switch_frame.setLayout(switcher) + self.widgets.analysis_menu_buttons = buttons + copy_layout = QHBoxLayout() + copy_layout.setContentsMargins(0, 0, self.theme['defaults']['margin'], 0) + copy_layout.setSpacing(self.theme['defaults']['csp']) + copy_combobox = self.create_combo_box(switch_frame) + copy_combobox.addItem('Selection') + copy_layout.addWidget(copy_combobox) + self.widgets.analysis_copy_combobox = copy_combobox + copy_button = self.create_icon_button(self.icons['copy'], 'Copy Data') + copy_button.clicked.connect(self.copy_analysis_callback) + copy_layout.addWidget(copy_button) + switch_layout.addLayout(copy_layout, 0, 2, alignment = ARIGHT | ABOTTOM) + switch_layout.setColumnStretch(2, 1) tabs = ( (dout_frame, 'analysis_table_dout', 'analysis_plot_dout'), @@ -479,7 +496,7 @@ def setup_analysis_frame(self): tab_layout.setSpacing(0) # graph - plot_frame = self.create_frame(tab, 'plot_widget', {'margin-right': 0}, SMINMAX) + plot_frame = self.create_frame(tab, 'plot_widget', size_policy=SMINMAX) plot_layout = QHBoxLayout() plot_layout.setContentsMargins(0, 0, 0, 0) plot_layout.setSpacing(self.theme['defaults']['isp']) @@ -646,11 +663,12 @@ def setup_settings_frame(self): dmg_hider_layout = QVBoxLayout() dmg_hider_frame = self.create_frame(col_1_frame, size_policy=SMINMAX, style_override= {'border-color':'@lbg', 'border-width':'@bw', 'border-style':'solid', 'border-radius': 2}) + self.set_buttons = list() for i, head in enumerate(TREE_HEADER[1:]): - bt = self.create_button(head, 'toggle_button', dmg_hider_frame, toggle=True) + bt = self.create_button(head, 'toggle_button', dmg_hider_frame, + toggle=self.settings.value(f'dmg_columns|{i}', type=bool)) bt.setSizePolicy(SMINMAX) - bt.setChecked(self.settings.value(f'dmg_columns|{i}', type=bool)) - bt.clicked.connect(lambda state, i=i: self.settings.setValue(f'dmg_columns|{i}', state)) + bt.clicked[bool].connect(lambda state, i=i: self.settings.setValue(f'dmg_columns|{i}', state)) dmg_hider_layout.addWidget(bt, stretch=1) dmg_seperator = self.create_frame(dmg_hider_frame, 'hr', style_override={'background-color': '@lbg'}, size_policy=SMINMIN) @@ -671,11 +689,10 @@ def setup_settings_frame(self): heal_hider_frame = self.create_frame(col_1_frame, size_policy=SMINMAX, style_override= {'border-color':'@lbg', 'border-width':'@bw', 'border-style':'solid', 'border-radius': 2}) for i, head in enumerate(HEAL_TREE_HEADER[1:]): - bt = self.create_button(head, 'toggle_button', heal_hider_frame) - bt.setCheckable(True) + bt = self.create_button(head, 'toggle_button', heal_hider_frame, + toggle=self.settings.value(f'heal_columns|{i}', type=bool)) bt.setSizePolicy(SMINMAX) - bt.setChecked(self.settings.value(f'heal_columns|{i}', type=bool)) - bt.clicked.connect(lambda state, i=i: self.settings.setValue(f'heal_columns|{i}', state)) + bt.clicked[bool].connect(lambda state, i=i: self.settings.setValue(f'heal_columns|{i}', state)) heal_hider_layout.addWidget(bt, stretch=1) heal_seperator = self.create_frame(dmg_hider_frame, 'hr', style_override={'background-color': '@lbg'}, size_policy=SMINMIN) diff --git a/OSCRUI/datafunctions.py b/OSCRUI/datafunctions.py index 7a9d291..c59c806 100644 --- a/OSCRUI/datafunctions.py +++ b/OSCRUI/datafunctions.py @@ -1,13 +1,12 @@ -from multiprocessing import Pipe, Process import os from PySide6.QtCore import QThread, Signal, Qt +from OSCR import OSCR, TREE_HEADER, HEAL_TREE_HEADER -from OSCR import OSCR - -from .datamodels import DamageTreeModel, HealTreeModel +from .datamodels import DamageTreeModel, HealTreeModel, TreeSelectionModel from .displayer import create_overview from .widgetbuilder import show_warning, log_size_warning, split_dialog +from .textedit import format_damage_tree_data, format_heal_tree_data class CustomThread(QThread): @@ -164,6 +163,7 @@ def populate_analysis(self, root_items: tuple): damage_out_table.expand(damage_out_model.index(0, 0, damage_out_model.createIndex(0, 0, damage_out_model._root))) damage_out_table.sortByColumn(1, Qt.SortOrder.AscendingOrder) + damage_out_table.setSelectionModel(TreeSelectionModel(damage_out_model)) damage_in_table = self.widgets.analysis_table_dtaken damage_in_model = DamageTreeModel(damage_in_item, self.theme_font('tree_table_header'), @@ -173,6 +173,7 @@ def populate_analysis(self, root_items: tuple): damage_in_table.expand(damage_in_model.index(0, 0, damage_in_model.createIndex(0, 0, damage_in_model._root))) damage_in_table.sortByColumn(1, Qt.SortOrder.AscendingOrder) + damage_in_table.setSelectionModel(TreeSelectionModel(damage_in_model)) heal_out_table = self.widgets.analysis_table_hout heal_out_model = HealTreeModel(heal_out_item, self.theme_font('tree_table_header'), @@ -182,6 +183,7 @@ def populate_analysis(self, root_items: tuple): heal_out_table.expand(heal_out_model.index(0, 0, damage_in_model.createIndex(0, 0, heal_out_model._root))) heal_out_table.sortByColumn(1, Qt.SortOrder.AscendingOrder) + heal_out_table.setSelectionModel(TreeSelectionModel(heal_out_model)) heal_in_table = self.widgets.analysis_table_hin heal_in_model = HealTreeModel(heal_in_item, self.theme_font('tree_table_header'), @@ -191,6 +193,7 @@ def populate_analysis(self, root_items: tuple): heal_in_table.expand(heal_in_model.index(0, 0, damage_in_model.createIndex(0, 0, heal_in_model._root))) heal_in_table.sortByColumn(1, Qt.SortOrder.AscendingOrder) + heal_in_table.setSelectionModel(TreeSelectionModel(heal_in_model)) update_shown_columns_dmg(self) update_shown_columns_heal(self) @@ -235,3 +238,38 @@ def resize_tree_table(tree): for col in range(tree.header().count()): width = max(tree.sizeHintForColumn(col), tree.header().sectionSizeHint(col)) + 5 tree.header().resizeSection(col, width) + +def copy_analysis_callback(self): + """ + Callback for copy button on analysis tab + """ + current_tab = self.widgets.analysis_tabber.currentIndex() + current_table = self.widgets.analysis_table[current_tab] + if current_tab <= 1: + current_header = TREE_HEADER + format_function = format_damage_tree_data + else: + current_header = HEAL_TREE_HEADER + format_function = format_heal_tree_data + copy_mode = self.widgets.analysis_copy_combobox.currentText() + if copy_mode == 'Selection': + selection = current_table.selectedIndexes() + if selection: + selection_dict = dict() + for selected_cell in selection: + column = selected_cell.column() + row_name = selected_cell.internalPointer().get_data(0) + if not row_name in selection_dict: + selection_dict[row_name] = dict() + if column != 0: + cell_data = selected_cell.internalPointer().get_data(column) + selection_dict[row_name][column] = cell_data + output = list() + for row_name, row_data in selection_dict.items(): + formatted_row = list() + for col, value in row_data.items(): + formatted_row.append(f'[{current_header[col]}] {format_function(value, col)}') + formatted_row_name = ''.join(row_name) if isinstance(row_name, tuple) else row_name + output.append(f"{formatted_row_name}: {' | '.join(formatted_row)}") + output_string = '\n'.join(output) + self.app.clipboard().setText(output_string) \ No newline at end of file diff --git a/OSCRUI/datamodels.py b/OSCRUI/datamodels.py index f8f6222..5d65e70 100644 --- a/OSCRUI/datamodels.py +++ b/OSCRUI/datamodels.py @@ -1,10 +1,14 @@ from typing import Iterable from PySide6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel, QAbstractItemModel, QModelIndex +from PySide6.QtCore import QItemSelectionModel, QItemSelection, Slot from PySide6.QtGui import QFont from OSCR import TreeItem -from .widgetbuilder import AVCENTER, ARIGHT, ACENTER, ALEFT +ARIGHT = Qt.AlignmentFlag.AlignRight +ALEFT = Qt.AlignmentFlag.AlignLeft +ACENTER = Qt.AlignmentFlag.AlignCenter +AVCENTER = Qt.AlignmentFlag.AlignVCenter class TableModel(QAbstractTableModel): def __init__(self, data, header: Iterable, index: Iterable, header_font: QFont, cell_font: QFont): @@ -287,4 +291,27 @@ def data(self, index: QModelIndex, role: int) -> str: elif role == -13: return index.internalPointer().get_data(column) return None - \ No newline at end of file + +class TreeSelectionModel(QItemSelectionModel): + """ + Implements custom selection behavior for analysis tables. + """ + def __init__(self, model: QAbstractItemModel): + super().__init__(model) + + def select(self, index_or_selection: QModelIndex | QItemSelection, + flag: QItemSelectionModel.SelectionFlag): + if isinstance(index_or_selection, QItemSelection): + try: + if index_or_selection.indexes()[0].column() == 0: + super().select(index_or_selection, flag | QItemSelectionModel.SelectionFlag.Rows) + else: + super().select(index_or_selection, flag) + # deselecting has empty list of indexes + except IndexError: + super().select(index_or_selection, QItemSelectionModel.SelectionFlag.Clear) + else: + if index_or_selection.isValid(): + super().select(index_or_selection, flag) + else: + self.clear() \ No newline at end of file diff --git a/OSCRUI/displayer.py b/OSCRUI/displayer.py index 4bada84..1d0463f 100644 --- a/OSCRUI/displayer.py +++ b/OSCRUI/displayer.py @@ -51,7 +51,8 @@ def plot_wrapper(self, data, time_reference=None): frame.setLayout(inner_layout) outer_layout = QVBoxLayout() outer_layout.setContentsMargins(0, 0, 0, 0) - outer_layout.addWidget(frame, stretch=11) # if stretch ever needs to be variable, create argument for decorator + # if stretch ever needs to be variable, create argument for decorator + outer_layout.addWidget(frame, stretch=11) return outer_layout return plot_wrapper diff --git a/OSCRUI/textedit.py b/OSCRUI/textedit.py index 521f2c6..576fa9b 100644 --- a/OSCRUI/textedit.py +++ b/OSCRUI/textedit.py @@ -27,6 +27,56 @@ def get_entity_num(id:str) -> int: except TypeError: return -1 +def format_damage_tree_data(data, column: int) -> str: + """ + Formats a data point according to TREE_HEADER + + Parameters: + - :param data: unformatted data point + - :param column: column of the data point in TREE_HEADER + + :return: formatted data point + """ + if data == '': + return '' + if column == 0: + if isinstance(data, tuple): + return ''.join(data) + return data + elif column in (3, 5, 6, 7): + return f'{data * 100:,.2f}%' + elif column in (1, 2, 4, 13, 14, 15, 16, 17, 18): + return f'{data:,.2f}' + elif column in (8, 9, 10, 11, 12, 20, 21): + return f'{data:,.0f}' + elif column == 19: + return f'{data}s' + +def format_heal_tree_data(data, column: int) -> str: + """ + Formats a data point according to HEAL_TREE_HEADER + + Parameters: + - :param data: unformatted data point + - :param column: column of the data point in HEAL_TREE_HEADER + + :return: formatted data point + """ + if data == '': + return '' + if column == 0: + if isinstance(data, tuple): + return ''.join(data) + return data + elif column == 8: + return f'{data * 100:,.2f}%' + elif column in (1, 2, 3, 4, 5, 6, 7, 17, 18): + return f'{data:,.2f}' + elif column in (9, 10, 12, 13): + return f'{data:,.0f}' + elif column == 11: + return f'{data}s' + def compensate_text(text:str) -> str: """ Unescapes various characters not correctly represented in combatlog files diff --git a/OSCRUI/widgetbuilder.py b/OSCRUI/widgetbuilder.py index d583f05..6e79da5 100644 --- a/OSCRUI/widgetbuilder.py +++ b/OSCRUI/widgetbuilder.py @@ -11,6 +11,7 @@ from .style import get_style_class, get_style, merge_style, theme_font from .textedit import format_path from .iofunctions import browse_path +from .datamodels import TreeSelectionModel CALLABLE = (FunctionType, BuiltinFunctionType, MethodType) @@ -172,7 +173,10 @@ def create_button_series(self, parent, buttons:dict, style, shape:str='row', sep toggle_button = detail['toggle'] if 'toggle' in detail else None bt = self.create_button(name, style, parent, button_style, toggle_button) if 'callback' in detail and isinstance(detail['callback'], CALLABLE): - bt.clicked.connect(detail['callback']) + if toggle_button: + bt.clicked[bool].connect(detail['callback']) + else: + bt.clicked.connect(detail['callback']) stretch = detail['stretch'] if 'stretch' in detail else 0 if 'align' in detail: layout.addWidget(bt, stretch, detail['align']) @@ -260,7 +264,7 @@ def create_analysis_table(self, parent, widget) -> QTreeView: table.setSortingEnabled(True) table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) table.header().setStyleSheet(get_style_class(self, 'QHeaderView', 'tree_table_header')) table.header().setSectionResizeMode(RFIXED) #table.header().setSectionsMovable(False) diff --git a/OSCRUI/widgets.py b/OSCRUI/widgets.py index 8cfd3ca..755de1a 100644 --- a/OSCRUI/widgets.py +++ b/OSCRUI/widgets.py @@ -23,6 +23,7 @@ def __init__(self): self.overview_tab_frames: list[QFrame] = list() self.analysis_menu_buttons: list[QPushButton] = list() + self.analysis_copy_combobox: QComboBox self.analysis_tabber: QTabWidget self.analysis_tab_frames: list[QFrame] = list() self.analysis_table_dout: QTreeView @@ -36,6 +37,11 @@ def __init__(self): self.ladder_map: QComboBox self.ladder_table: QTableView + + @property + def analysis_table(self): + return (self.analysis_table_dout, self.analysis_table_dtaken, self.analysis_plot_hout, + self.analysis_plot_hin) class FlipButton(QPushButton): """ @@ -234,7 +240,7 @@ def clear_plot(self): self._legend_queue = list() self._bar_position = 0 - def toggle_freeze(self): + def toggle_freeze(self, state): """ Freezes when unfrozen, unfreezes when frozen """ diff --git a/main.py b/main.py index f8a2326..6b113c6 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ class Launcher(): - version = '2024.3a11' + version = '2024.3a30' # holds the style of the app theme = { @@ -399,7 +399,7 @@ class Launcher(): 'background-color': '@bg', 'alternate-background-color': '@mbg', 'color': '@fg', - 'margin': (10, 0, 10, 0), + 'margin': (10, 10, 10, 0), 'outline': '0', # removes dotted line around clicked item 'font': ('Overpass', 12, 'Normal'), '::item': {