Coverage for graphica/gui/plugin_context.py: 95.8%

102 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""PluginContext の本体側の実装。1つのタブ(PlotterApp)と1つのプラグインの組ごとに作る。""" 

2import logging 

3import weakref 

4from contextlib import contextmanager 

5from dataclasses import fields 

6 

7 

8from graphica.gui import notify 

9from graphica.core.app_paths import get_plugin_data_dir 

10from graphica.core.color_palettes import normalize_palettes 

11from graphica.core.commands import SetDatasetPropertiesCommand 

12from graphica.core.dataset import Dataset 

13from graphica.core.named_colors import add_named_color, load_named_colors, save_named_colors 

14from graphica.core.plugin_context import PluginContext 

15 

16logger = logging.getLogger(__name__) 

17 

18_DATASET_FIELDS = frozenset(f.name for f in fields(Dataset)) 

19 

20 

21class TabPluginContext(PluginContext): 

22 

23 def __init__(self, app, plugin_name): 

24 # タブを閉じたあとも窓口をプラグインが握っていることがあるので、タブは弱参照で持つ。 

25 self._app_ref = weakref.ref(app) 

26 self._plugin_name = plugin_name 

27 self._datasets_changed_callbacks = [] 

28 self._selection_changed_callbacks = [] 

29 # undo_group の中なら {"description", "started"}。マクロは最初の変更で始める(空のマクロも Undo 1回分になる) 

30 self._undo_group = None 

31 

32 @property 

33 def _app(self): 

34 app = self._app_ref() 

35 if app is None: 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true

36 raise RuntimeError("このタブはすでに閉じられています。") 

37 return app 

38 

39 def datasets(self): 

40 return list(self._app.project.datasets) 

41 

42 def current_dataset(self): 

43 return self._app.current_dataset() 

44 

45 def selected_datasets(self): 

46 return self._app.selected_datasets() 

47 

48 def add_dataset(self, dataset, description=None): 

49 self._start_pending_undo_group() 

50 self._app.add_dataset_with_undo( 

51 dataset, description=description or f"[{self._plugin_name}] データセットの追加" 

52 ) 

53 

54 def set_dataset_properties(self, dataset, values, description=None): 

55 unknown = sorted(set(values) - _DATASET_FIELDS) 

56 if unknown: 

57 raise AttributeError(f"Dataset に無い属性です: {', '.join(unknown)}") 

58 app = self._app 

59 new_values = dict(values) 

60 old_values = {key: getattr(dataset, key) for key in new_values} 

61 self._start_pending_undo_group() 

62 app.undo_stack.push(SetDatasetPropertiesCommand( 

63 dataset, old_values, new_values, 

64 on_applied=lambda: app.refresh_after_dataset_property_change( 

65 dataset, changed_keys=new_values.keys(), old_values=old_values, new_values=new_values 

66 ), 

67 description=description or f"[{self._plugin_name}] プロパティの変更", 

68 )) 

69 

70 @contextmanager 

71 def undo_group(self, description): 

72 outermost = self._undo_group is None 

73 if outermost: 

74 self._undo_group = {"description": description, "started": False} 

75 try: 

76 yield 

77 finally: 

78 if outermost: 

79 group, self._undo_group = self._undo_group, None 

80 if group["started"]: 

81 self._app.end_batched_undo_group() 

82 

83 def _start_pending_undo_group(self): 

84 group = self._undo_group 

85 if group is not None and not group["started"]: 

86 self._app.begin_batched_undo_group(group["description"]) 

87 group["started"] = True 

88 

89 def redraw(self): 

90 self._app.redraw() 

91 

92 def on_datasets_changed(self, callback): 

93 self._datasets_changed_callbacks.append(callback) 

94 

95 def on_selection_changed(self, callback): 

96 self._selection_changed_callbacks.append(callback) 

97 

98 def _notify_datasets_changed(self): 

99 for callback in list(self._datasets_changed_callbacks): 

100 self._call_isolated(callback) 

101 

102 def _notify_selection_changed(self, dataset): 

103 for callback in list(self._selection_changed_callbacks): 

104 self._call_isolated(callback, dataset) 

105 

106 def _call_isolated(self, callback, *args): 

107 # 通知先のプラグインが失敗しても、描画や選択の処理を止めない。 

108 try: 

109 callback(*args) 

110 except Exception: 

111 logger.exception("[plugin:%s] 通知の処理に失敗しました", self._plugin_name) 

112 

113 @property 

114 def parent_widget(self): 

115 return self._app_ref() 

116 

117 def show_message(self, text, title=None): 

118 notify.information(self.parent_widget, title or self._plugin_name, text) 

119 

120 def show_error(self, text, title=None): 

121 notify.warning(self.parent_widget, title or self._plugin_name, text) 

122 

123 @property 

124 def data_dir(self): 

125 return get_plugin_data_dir(self._plugin_name) 

126 

127 def named_colors(self): 

128 return load_named_colors(self._app.settings) 

129 

130 def set_named_colors(self, entries): 

131 validated = [] 

132 for entry in entries: 

133 validated = add_named_color(validated, entry.get("name"), entry.get("color")) 

134 save_named_colors(self._app.settings, validated) 

135 

136 def color_palettes(self): 

137 return {name: list(colors) for name, colors in self._app.colors.load_palettes().items()} 

138 

139 def set_color_palettes(self, palettes): 

140 self._app.colors.save_palettes(normalize_palettes(palettes)) 

141 

142 def active_color_cycle(self): 

143 return list(self._app.colors.active_color_cycle()) 

144 

145 def set_active_color_palette(self, name): 

146 self._app.colors.set_active_palette(name)