├── LICENSE ├── README.md ├── data ├── css │ └── flow.css ├── libflow.gresource.xml ├── meson.build └── ui │ ├── node-view.blp │ └── node.blp ├── example ├── main.vala └── meson.build ├── libflow.doap ├── meson.build ├── result.png ├── src ├── css-loader.vala ├── deps.in ├── meson.build ├── minimap.vala ├── node-view.vala ├── node.vala ├── renderers │ ├── connection-renderer.vala │ └── socket-renderer.vala ├── rubberband.vala ├── sink.vala ├── socket.vala ├── source.vala └── title-style.vala └── tests ├── meson.build └── node-view-test.vala /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 PaladinDev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # libflow 2 | [![Telegram group badge](https://img.shields.io/badge/Telegram-Join_the_chat-2CA5E0?style=flat&logo=telegram)](https://t.me/vala_lang) 3 | Flow Graph view for Gtk4 4 | 5 | ![Screenshot](./result.png) 6 | 7 | ## Installation 8 | 9 | ### ArchLinux (AUR) 10 | 11 | $ yay -S libflow-git 12 | 13 | ### Build manualy 14 | 15 | $ meson setup --prefix=/usr --buildtype=release build 16 | $ ninja -C build install 17 | -------------------------------------------------------------------------------- /data/css/flow.css: -------------------------------------------------------------------------------- 1 | node { 2 | background-color: @card_bg_color; 3 | color: @card_fg_color; 4 | border-radius: 12px; 5 | box-shadow: 6 | 0 0 0 1px rgba(0, 0, 0, 0.03), 7 | 0 1px 3px 1px rgba(0, 0, 0, 0.07), 8 | 0 2px 6px 2px rgba(0, 0, 0, 0.03); 9 | } 10 | 11 | node:selected { 12 | background-color: alpha(@accent_color, 0.2); 13 | } 14 | 15 | node #title-container { 16 | border-radius: 12px 12px 0px 0px; 17 | padding: 10px; 18 | } 19 | 20 | node #title-container.shadow { 21 | box-shadow: 0 1px rgba(0,0,0,0.18), 0 2px 4px rgba(0,0,0,0.18); 22 | background-color: @card_bg_color; 23 | } 24 | 25 | node #title-container.separator { 26 | border-bottom: 1px solid alpha(currentColor,0.15); 27 | } 28 | 29 | node #socket-container { 30 | padding: 10px; 31 | padding-top: 5px; 32 | padding-bottom: 5px; 33 | border-spacing: 5px; 34 | } 35 | 36 | node #sink-container, #source-container { 37 | border-spacing: 5px; 38 | } 39 | 40 | node #content { 41 | padding: 10px; 42 | padding-top: 0px; 43 | } 44 | 45 | rubberband { 46 | border-radius: 8px; 47 | } 48 | 49 | -------------------------------------------------------------------------------- /data/libflow.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | css/flow.css 5 | ui/node.ui 6 | ui/node-view.ui 7 | 8 | 9 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | blueprints = files( 2 | 'ui/node.blp', 3 | 'ui/node-view.blp', 4 | ) 5 | 6 | blp_target = custom_target( 7 | 'blueprints', 8 | 9 | input: blueprints, 10 | output: '.', 11 | command: [ find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@' ], 12 | ) 13 | 14 | lib_resources = gnome.compile_resources( 15 | 'libflow-resources', 16 | 'libflow.gresource.xml', 17 | dependencies: blp_target 18 | ) 19 | 20 | if get_option('buildtype') == 'debug' 21 | # Don't do it in debug mode to make VLS works 22 | compiler_dependency = [] 23 | else 24 | # Creates empty vala file to fix build order issue with blueprints 25 | compiler_dependency = custom_target( 26 | 'compiler_dependency', 27 | 28 | depends: lib_resources, 29 | output: 'dependency.vala', 30 | command: [ find_program('touch'), '@OUTPUT@' ] 31 | ) 32 | endif -------------------------------------------------------------------------------- /data/ui/node-view.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $FlowNodeView : Widget { 4 | 5 | GestureDrag drag { 6 | drag-begin => $start_drag(); 7 | drag-update => $process_drag(); 8 | drag-end => $stop_drag(); 9 | } 10 | 11 | GestureClick secondary_click { 12 | button: 3; 13 | pressed => $open_menu(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /data/ui/node.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $FlowNode : $FlowNodeRenderer { 4 | 5 | Box main_box { 6 | name: "node-container"; 7 | orientation: vertical; 8 | 9 | Box title_box { 10 | name: "title-container"; 11 | orientation: vertical; 12 | } 13 | 14 | Box socket_box { 15 | name: "socket-container"; 16 | orientation: horizontal; 17 | 18 | Box sink_box { 19 | name: "sink-container"; 20 | orientation: vertical; 21 | hexpand: true; 22 | } 23 | 24 | Box source_box { 25 | name: "source-container"; 26 | orientation: vertical; 27 | } 28 | } 29 | 30 | Box content_box { 31 | name: "content"; 32 | orientation: vertical; 33 | visible: false; 34 | } 35 | } 36 | 37 | PopoverMenu menu { 38 | has-arrow: false; 39 | menu-model: delete_menu; 40 | } 41 | 42 | GestureDrag drag { 43 | drag-begin => $begin_drag(); 44 | } 45 | 46 | GestureClick secondary_click { 47 | button: 3; 48 | pressed => $open_menu(); 49 | } 50 | } 51 | 52 | menu delete_menu { 53 | item { 54 | label: _("Delete"); 55 | action: "node.delete"; 56 | } 57 | 58 | item { 59 | label: _("Unlink all"); 60 | action: "node.unlink-all"; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /example/main.vala: -------------------------------------------------------------------------------- 1 | public class NumberGeneratorNode : Flow.Node { 2 | 3 | public NumberGeneratorNode() { 4 | add_css_class("green"); 5 | title_style = Flow.TitleStyle.SEPARATOR; 6 | set_label_name("NumberGenerator", Gtk.Align.START); 7 | highlight_color = { 0.20F, 0.82F, 0.48F, 0.3F }; 8 | 9 | var number_source = new Flow.Source.with_type(Type.DOUBLE) { 10 | color = { 1, 1, 0, 1 }, 11 | name = "output" 12 | }; 13 | number_source.set_value(0d); 14 | add_source(number_source); 15 | 16 | var spin_button = new Gtk.SpinButton(new Gtk.Adjustment(0, 0, 100, 1, 10, 0), 0, 0); 17 | spin_button.set_size_request(50,20); 18 | spin_button.value_changed.connect(() => { 19 | number_source.set_value(spin_button.get_value()); 20 | }); 21 | 22 | content = spin_button; 23 | } 24 | } 25 | 26 | public class OperationNode : Flow.Node { 27 | private double operand_a_value = 0; 28 | private double operand_b_value = 0; 29 | private string operation = "+"; 30 | 31 | private Flow.Source result; 32 | 33 | public OperationNode() { 34 | add_css_class("yellow"); 35 | title_style = Flow.TitleStyle.SEPARATOR; 36 | set_label_name("Operation", Gtk.Align.START); 37 | highlight_color = { 0.96F, 0.83F, 0.18F, 0.3F }; 38 | 39 | result = new Flow.Source.with_type(Type.DOUBLE) { 40 | color = { 1, 0, 1, 1 }, 41 | name = "result" 42 | }; 43 | 44 | var summand_a = new Flow.Sink.with_type(Type.DOUBLE) { 45 | color = { 0, 1, 1, 1 }, 46 | name = "operand A" 47 | }; 48 | summand_a.changed.connect(@value => { 49 | if (@value == null) { 50 | return; 51 | } 52 | operand_a_value = @value.get_double(); 53 | publish_result(); 54 | }); 55 | 56 | var summand_b = new Flow.Sink.with_type(Type.DOUBLE) { 57 | color = { 0, 1, 0, 1 }, 58 | name = "operand B" 59 | }; 60 | summand_b.changed.connect(@value => { 61 | if (@value == null) 62 | return; 63 | 64 | operand_b_value = @value.get_double(); 65 | publish_result(); 66 | }); 67 | 68 | add_source(result); 69 | add_sink(summand_a); 70 | add_sink(summand_b); 71 | 72 | string[] operations = { "+", "-", "*", "/" }; 73 | var drop_down = new Gtk.DropDown.from_strings(operations); 74 | drop_down.notify["selected-item"].connect(() => { 75 | operation = operations[drop_down.get_selected()]; 76 | publish_result(); 77 | }); 78 | 79 | content = drop_down; 80 | } 81 | 82 | private void publish_result() { 83 | if (operation == "+") { 84 | set_result(operand_a_value + operand_b_value); 85 | } else if (operation == "-") { 86 | set_result(operand_a_value - operand_b_value); 87 | } else if (operation == "*") { 88 | set_result(operand_a_value * operand_b_value); 89 | } else if (operation == "/") { 90 | set_result(operand_a_value / operand_b_value); 91 | } 92 | } 93 | 94 | private void set_result(double operation_result) { 95 | result.set_value(operation_result); 96 | } 97 | } 98 | 99 | public class PrintNode : Flow.Node { 100 | public Gtk.Label label; 101 | 102 | public PrintNode() { 103 | add_css_class("blue"); 104 | title_style = Flow.TitleStyle.SEPARATOR; 105 | set_label_name("Output", Gtk.Align.START); 106 | highlight_color = { 0.21F, 0.52F, 0.89F, 0.3f }; 107 | 108 | var number = new Flow.Sink.with_type(Type.DOUBLE) { 109 | color = { 0, 0, 1, 1 }, 110 | name = "input" 111 | }; 112 | number.changed.connect(display_value); 113 | 114 | add_sink(number); 115 | 116 | content = label = new Gtk.Label(""); 117 | } 118 | 119 | private void display_value(Value? @value) { 120 | if (@value == null) { 121 | return; 122 | } 123 | 124 | var text = @value.strdup_contents(); 125 | var dot_index = text.index_of_char('.', 0); 126 | 127 | if (text.get_char(dot_index + 1) == '0') 128 | text = text.substring(0, dot_index); 129 | else 130 | text = text.substring(0, dot_index + 2); 131 | 132 | label.set_text(text); 133 | } 134 | } 135 | 136 | public class AdvancedCalculatorWindow : Gtk.ApplicationWindow { 137 | private Flow.NodeView node_view; 138 | private Gtk.Box menu_content; 139 | 140 | public AdvancedCalculatorWindow(Gtk.Application app) { 141 | application = app; 142 | 143 | set_default_size(600, 550); 144 | init_header_bar(); 145 | init_window_layout(); 146 | init_actions(); 147 | init_stylesheet(); 148 | } 149 | 150 | private void init_header_bar() { 151 | set_titlebar(new Gtk.HeaderBar() { 152 | title_widget = new Gtk.Label("libflow Example") { 153 | use_markup = true 154 | } 155 | }); 156 | } 157 | 158 | private void init_window_layout() { 159 | Gtk.Overlay overlay; 160 | 161 | set_child(overlay = new Gtk.Overlay() { 162 | child = new Gtk.ScrolledWindow() { 163 | child = this.node_view = new Flow.NodeView() 164 | } 165 | }); 166 | 167 | overlay.add_overlay(new Flow.Minimap() { 168 | halign = Gtk.Align.END, 169 | valign = Gtk.Align.END, 170 | nodeview = node_view, 171 | can_target = false 172 | }); 173 | } 174 | 175 | private void init_actions() { 176 | menu_content = new Gtk.Box(Gtk.Orientation.VERTICAL, 10); 177 | node_view.menu_content = menu_content; 178 | 179 | add_number_node_action(); 180 | add_operation_node_action(); 181 | add_print_node_action(); 182 | } 183 | 184 | private void init_stylesheet() { 185 | var provider = new Gtk.CssProvider(); 186 | provider.load_from_string( 187 | """node.green #title-container { 188 | background-color: alpha(@green_3, 0.2); 189 | color: @green_3; 190 | } 191 | node.yellow #title-container { 192 | background-color: alpha(@yellow_3, 0.2); 193 | color: @yellow_3; 194 | } 195 | node.blue #title-container { 196 | background-color: alpha(@blue_3, 0.2); 197 | color: @blue_3; 198 | } 199 | """ 200 | ); 201 | 202 | Gtk.StyleContext.add_provider_for_display( 203 | Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 204 | ); 205 | } 206 | 207 | private void add_number_node_action() { 208 | var button = new Gtk.Button.with_label("NumberGenerator"); 209 | button.clicked.connect(() => { 210 | node_view.add(new NumberGeneratorNode() { x = 20, y = 20 }); 211 | }); 212 | button.set_has_frame(false); 213 | menu_content.append(button); 214 | } 215 | 216 | private void add_operation_node_action() { 217 | var button = new Gtk.Button.with_label("Operation"); 218 | button.clicked.connect(() => { 219 | node_view.add(new OperationNode() { x = 200, y = 20 }); 220 | }); 221 | button.set_has_frame(false); 222 | menu_content.append(button); 223 | } 224 | 225 | private void add_print_node_action() { 226 | var button = new Gtk.Button.with_label("Print"); 227 | button.clicked.connect(() => { 228 | node_view.add(new PrintNode() { x = 400, y = 20 }); 229 | }); 230 | button.set_has_frame(false); 231 | menu_content.append(button); 232 | } 233 | } 234 | 235 | int main(string[] argv) { 236 | var app = new Gtk.Application( 237 | "com.example.GtkApplication", 238 | ApplicationFlags.FLAGS_NONE 239 | ); 240 | 241 | app.activate.connect(() => new AdvancedCalculatorWindow(app).present()); 242 | 243 | return app.run(argv); 244 | } 245 | -------------------------------------------------------------------------------- /example/meson.build: -------------------------------------------------------------------------------- 1 | executable( 2 | 'libflow-sample', 3 | 4 | files('main.vala'), 5 | vala_args: '--target-glib=2.58', 6 | dependencies: [ 7 | dependency('gtk4'), 8 | lib_dependency 9 | ] 10 | ) 11 | -------------------------------------------------------------------------------- /libflow.doap: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | libflow 10 | FlowGraph view for Gtk4 11 | Library to create Flow Graphs for Gtk4 12 | 13 | 14 | 15 | Vala 16 | 17 | 18 | 19 | PaladinDev 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project( 2 | 'libflow', 3 | 4 | [ 'c', 'vala' ], 5 | version: '1.0.0', 6 | meson_version: '>= 0.50.0', 7 | ) 8 | 9 | pkg = import('pkgconfig') 10 | gnome = import('gnome') 11 | 12 | api_version = '1.0' 13 | lib_name = meson.project_name() + '-' + api_version 14 | gir_name = 'Flow-' + api_version 15 | 16 | subdir('data') 17 | subdir('src') 18 | 19 | subdir('tests') 20 | 21 | subdir('example') 22 | -------------------------------------------------------------------------------- /result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpikedPaladin/libflow/3abd447a0d2885663b5c307ae366c7e696f5eb82/result.png -------------------------------------------------------------------------------- /src/css-loader.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | [SingleInstance] 4 | protected class CssLoader : Object { 5 | public Gtk.CssProvider provider; 6 | 7 | public void ensure() { 8 | if (provider != null) return; 9 | 10 | provider = new Gtk.CssProvider(); 11 | provider.load_from_resource("/me/paladin/libflow/css/flow.css"); 12 | Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/deps.in: -------------------------------------------------------------------------------- 1 | gtk4 -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | lib_deps = [ 2 | dependency('gtk4') 3 | ] 4 | 5 | lib_sources = files( 6 | 'css-loader.vala', 7 | 'minimap.vala', 8 | 'node.vala', 9 | 'node-view.vala', 10 | 'rubberband.vala', 11 | 'sink.vala', 12 | 'socket.vala', 13 | 'source.vala', 14 | 'title-style.vala', 15 | 16 | 'renderers/connection-renderer.vala', 17 | 'renderers/socket-renderer.vala', 18 | ) 19 | 20 | install_data( 21 | 'deps.in', 22 | 23 | rename: lib_name + '.deps', 24 | install_dir: get_option('datadir') / 'vala' / 'vapi' 25 | ) 26 | 27 | lib = library( 28 | lib_name, 29 | 30 | compiler_dependency, # Required to build blueprints first 31 | lib_resources, 32 | lib_sources, 33 | dependencies: lib_deps, 34 | vala_gir: gir_name + '.gir', 35 | vala_args: [ '--gresourcesdir=data/' ], 36 | install: true, 37 | install_dir: [true, true, true, true] 38 | ) 39 | 40 | pkg.generate( 41 | name: meson.project_name(), 42 | description: 'Flow Graph library for Gtk4', 43 | 44 | libraries: lib, 45 | version: meson.project_version(), 46 | subdirs: lib_name, 47 | filebase: lib_name, 48 | requires: ['gtk4'] 49 | ) 50 | 51 | custom_target( 52 | 'typelib', 53 | 54 | command: [ 55 | find_program('g-ir-compiler'), 56 | 57 | '--shared-library=' + lib.full_path().split('/')[-1], 58 | '--output=@OUTPUT@', 59 | meson.current_build_dir() / (gir_name + '.gir') 60 | ], 61 | output: gir_name + '.typelib', 62 | depends: lib, 63 | install: true, 64 | install_dir: get_option('libdir') / 'girepository-1.0' 65 | ) 66 | 67 | lib_dependency = declare_dependency( 68 | link_with: lib, 69 | include_directories: include_directories('.') 70 | ) 71 | -------------------------------------------------------------------------------- /src/minimap.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | /** 4 | * A Widget that draws a minmap of a {@link Flow.NodeView} 5 | * 6 | * Please set the nodeview property after integrating the referenced 7 | * {@link Flow.NodeView} into its respective container 8 | */ 9 | public class Minimap : Gtk.DrawingArea { 10 | private NodeView? _nodeview = null; 11 | private Gtk.ScrolledWindow? _scrolledwindow = null; 12 | private Gtk.EventControllerMotion ctr_motion; 13 | private Gtk.GestureClick ctr_click; 14 | 15 | private ulong draw_signal = 0; 16 | private ulong hadjustment_signal = 0; 17 | private ulong vadjustment_signal = 0; 18 | 19 | private int offset_x = 0; 20 | private int offset_y = 0; 21 | private double ratio = 0.0; 22 | private int rubber_width = 0; 23 | private int rubber_height = 0; 24 | private bool move_rubber = false; 25 | /** 26 | * The nodeview that this Minimap should depict 27 | * 28 | * You may either add a {@link Flow.NodeView} directly or a 29 | * {@link Gtk.ScrolledWindow} that contains a {@link Flow.NodeView} 30 | * as its child. 31 | */ 32 | public NodeView nodeview { 33 | get { 34 | return _nodeview; 35 | } 36 | set { 37 | if (_nodeview != null) 38 | SignalHandler.disconnect(_nodeview, draw_signal); 39 | 40 | if (_scrolledwindow != null) { 41 | SignalHandler.disconnect(_nodeview, hadjustment_signal); 42 | SignalHandler.disconnect(_nodeview, vadjustment_signal); 43 | } 44 | if (value == null) { 45 | _nodeview = null; 46 | _scrolledwindow = null; 47 | } else { 48 | _nodeview = value; 49 | _scrolledwindow = null; 50 | if (value.parent is Gtk.ScrolledWindow) { 51 | _scrolledwindow = value.parent as Gtk.ScrolledWindow; 52 | } else { 53 | if (value.parent is Gtk.Viewport) { 54 | if (value.parent.parent is Gtk.ScrolledWindow) { 55 | _scrolledwindow = value.parent.parent as Gtk.ScrolledWindow; 56 | hadjustment_signal = _scrolledwindow.hadjustment.notify["value"].connect(queue_draw); 57 | vadjustment_signal = _scrolledwindow.vadjustment.notify["value"].connect(queue_draw); 58 | } 59 | } 60 | } 61 | draw_signal = _nodeview.draw_minimap.connect(queue_draw); 62 | } 63 | queue_draw(); 64 | } 65 | } 66 | 67 | static construct { 68 | set_css_name("minimap"); 69 | } 70 | 71 | /** 72 | * Create a new Minimap 73 | */ 74 | public Minimap() { 75 | set_size_request(50, 50); 76 | 77 | ctr_motion = new Gtk.EventControllerMotion(); 78 | add_controller(ctr_motion); 79 | ctr_click = new Gtk.GestureClick(); 80 | add_controller(ctr_click); 81 | 82 | ctr_click.pressed.connect((n, x, y) => do_button_press_event(x, y)); 83 | ctr_click.end.connect(do_button_release_event); 84 | ctr_motion.motion.connect(do_motion_notify_event); 85 | } 86 | 87 | private void do_button_press_event(double x, double y) { 88 | move_rubber = true; 89 | double halloc = double.max(0, x - offset_x - rubber_width / 2) * ratio; 90 | double valloc = double.max(0, y - offset_y - rubber_height / 2) * ratio; 91 | _scrolledwindow.hadjustment.value = halloc; 92 | _scrolledwindow.vadjustment.value = valloc; 93 | } 94 | 95 | private void do_motion_notify_event(double x, double y) { 96 | if (!move_rubber || _scrolledwindow == null) { 97 | return; 98 | } 99 | double halloc = double.max(0, x - offset_x - rubber_width / 2) * ratio; 100 | double valloc = double.max(0, y - offset_y - rubber_height / 2) * ratio; 101 | _scrolledwindow.hadjustment.value = halloc; 102 | _scrolledwindow.vadjustment.value = valloc; 103 | } 104 | 105 | private void do_button_release_event() { 106 | move_rubber = false; 107 | } 108 | 109 | /** 110 | * Draws the minimap. This method is called internally 111 | */ 112 | public override void snapshot(Gtk.Snapshot snapshot) { 113 | Graphene.Rect rect; 114 | 115 | if (_nodeview != null) { 116 | offset_x = 0; 117 | offset_y = 0; 118 | int height = 0; 119 | int width = 0; 120 | if (get_width() > get_height()) { 121 | width = (int) ((double) _nodeview.get_width() / _nodeview.get_height() * get_height()); 122 | height = get_height(); 123 | offset_x = (get_width() - width) / 2; 124 | } else { 125 | height = (int) ((double) _nodeview.get_height() / _nodeview.get_width() * get_width()); 126 | width = get_width(); 127 | offset_y = (get_height() - height) / 2; 128 | } 129 | ratio = (double) _nodeview.get_width() / width; 130 | 131 | for (var child = _nodeview.get_first_child(); child != null; child = child.get_next_sibling()) { 132 | if (!(child is NodeRenderer)) 133 | continue; 134 | 135 | var node = (Node) child; 136 | 137 | Gdk.RGBA color; 138 | Graphene.Rect bounds; 139 | node.compute_bounds(_nodeview, out bounds); 140 | 141 | if (node.highlight_color != null) { 142 | color = node.highlight_color; 143 | } else { 144 | color = { 0.4f, 0.4f, 0.4f, 0.5f }; 145 | } 146 | 147 | rect = Graphene.Rect().init( 148 | (int) (offset_x + bounds.origin.x / ratio), 149 | (int) (offset_y + bounds.origin.y / ratio), 150 | (int) (bounds.size.width / ratio), 151 | (int) (bounds.size.height / ratio) 152 | ); 153 | 154 | snapshot.append_color(color, rect); 155 | } 156 | if (_scrolledwindow != null) { 157 | if (_scrolledwindow.get_width() < _nodeview.get_width() || _scrolledwindow.get_height() < _nodeview.get_height()) { 158 | rect = Graphene.Rect().init( 159 | (int) (offset_x + _scrolledwindow.hadjustment.value / ratio), 160 | (int) (offset_y + _scrolledwindow.vadjustment.value / ratio), 161 | (int) (_scrolledwindow.get_width() / ratio), 162 | (int) (_scrolledwindow.get_height() / ratio) 163 | ); 164 | 165 | snapshot.append_color({ 0.0f, 0.2f, 0.6f, 0.5f }, rect); 166 | } 167 | } 168 | } 169 | } 170 | } 171 | } 172 | 173 | -------------------------------------------------------------------------------- /src/node-view.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | [GtkTemplate (ui = "/me/paladin/libflow/ui/node-view.ui")] 4 | public class NodeView : Gtk.Widget { 5 | [GtkChild] 6 | private unowned Gtk.GestureDrag drag; 7 | private Gtk.Popover menu; 8 | private Gtk.Widget _menu_content; 9 | private int _grid_size = 1; 10 | /** 11 | * If this property is set to true, the nodeview will not perform 12 | * any check wheter newly created connections will result in cycles 13 | * in the graph. It's completely up to the application programmer 14 | * to make sure that the logic inside the nodes he uses avoids 15 | * endlessly backpropagated loops 16 | */ 17 | public bool allow_recursion { get; set; default = false; } 18 | 19 | public Gtk.Widget menu_content { get { return _menu_content; } set { _menu_content = value; menu.set_child(value); } } 20 | public int grid_size { get { return _grid_size; } set { if (value < 1) _grid_size = 1; else _grid_size = value; } } 21 | /** 22 | * The current extents of the temporary connector 23 | * if null, there is no temporary connector drawn at the moment 24 | */ 25 | private Gdk.Rectangle? temp_connector = null; 26 | /** 27 | * The socket that the temporary connector will be attched to 28 | */ 29 | private Socket? temp_connected_socket = null; 30 | /** 31 | * The socket that was clicked to invoke the temporary connector 32 | */ 33 | private Socket? clicked_socket = null; 34 | /** 35 | * Widget that used to draw selection 36 | */ 37 | public Rubberband? rubberband; 38 | /** 39 | * The node that is being moved right now via mouse drag. 40 | * The node that receives the button press event registers 41 | * itself with this property 42 | */ 43 | internal NodeRenderer? move_node { get; set; default = null; } 44 | internal NodeRenderer? resize_node { get; set; default = null; } 45 | 46 | public ConnectionRenderer renderer = new ConnectionRenderer(); 47 | 48 | static construct { 49 | set_css_name("node-view"); 50 | } 51 | 52 | construct { 53 | new CssLoader().ensure(); 54 | 55 | set_layout_manager(new NodeViewLayoutManager()); 56 | } 57 | 58 | /** 59 | * Instantiate a new NodeView 60 | */ 61 | public NodeView() { 62 | menu = new Gtk.Popover(); 63 | menu.set_parent(this); 64 | menu.set_has_arrow(false); 65 | } 66 | 67 | /** 68 | * {@inheritDoc} 69 | */ 70 | public override void dispose() { 71 | var child = get_first_child(); 72 | 73 | while (child != null) { 74 | var delchild = child; 75 | child = child.get_next_sibling(); 76 | delchild.unparent(); 77 | } 78 | 79 | base.dispose(); 80 | } 81 | 82 | public NodeViewLayoutChild get_layout(Gtk.Widget widget) { 83 | return (NodeViewLayoutChild) layout_manager.get_layout_child(widget); 84 | } 85 | 86 | public List get_nodes() { 87 | var result = new List(); 88 | 89 | for (var child = get_first_child(); child != null; child = child.get_next_sibling()) { 90 | if (!(child is NodeRenderer)) 91 | continue; 92 | 93 | result.append(child as NodeRenderer); 94 | } 95 | 96 | return result; 97 | } 98 | 99 | public List get_selected_nodes() { 100 | var result = new List(); 101 | 102 | foreach (var node in get_nodes()) { 103 | if (node.selected) 104 | result.append(node); 105 | } 106 | 107 | return result; 108 | } 109 | 110 | private int round_to_multiply(int number, int multiply) { 111 | return (int) (multiply * Math.round(number / multiply)); 112 | } 113 | 114 | [GtkCallback] 115 | private void start_drag(double x, double y) { 116 | if (pick(x, y, Gtk.PickFlags.DEFAULT) == this) { 117 | rubberband?.unparent(); 118 | rubberband = new Rubberband(this, x, y); 119 | } 120 | } 121 | 122 | [GtkCallback] 123 | private void process_drag(double offset_x, double offset_y) { 124 | double start_x, start_y, x, y; 125 | drag.get_start_point(out start_x, out start_y); 126 | x = start_x + offset_x; 127 | y = start_y + offset_y; 128 | 129 | if (move_node != null) { 130 | int old_x = move_node.x; 131 | int old_y = move_node.y; 132 | 133 | int new_x = ((int) x - (int) move_node.click_offset_x); 134 | int new_y = ((int) y - (int) move_node.click_offset_y); 135 | 136 | if (old_x == new_x && old_y == new_y) 137 | return; 138 | 139 | if (new_x < 0) new_x = 0; 140 | if (new_y < 0) new_y = 0; 141 | 142 | move_node.x = _grid_size == 1 ? new_x : round_to_multiply(new_x, _grid_size); 143 | move_node.y = _grid_size == 1 ? new_y : round_to_multiply(new_y, _grid_size); 144 | 145 | if (move_node.selected) { 146 | foreach (NodeRenderer node in get_selected_nodes()) { 147 | if (node == move_node) continue; 148 | 149 | node.x -= old_x - move_node.x; 150 | node.y -= old_y - move_node.y; 151 | } 152 | } 153 | 154 | queue_allocate(); 155 | } 156 | 157 | if (resize_node != null) { 158 | int d_x, d_y; 159 | Graphene.Rect node_bounds; 160 | 161 | resize_node.compute_bounds(this, out node_bounds); 162 | 163 | d_x = (int) (x - resize_node.click_offset_x - node_bounds.origin.x); 164 | d_y = (int) (y - resize_node.click_offset_y - node_bounds.origin.y); 165 | 166 | int new_width = (int) resize_node.resize_start_width + d_x; 167 | int new_height = (int) resize_node.resize_start_height + d_y; 168 | 169 | if (new_width < 0) new_width = 0; 170 | if (new_height < 0) new_height = 0; 171 | 172 | resize_node.set_size_request(new_width, new_height); 173 | } 174 | 175 | if (temp_connector != null) { 176 | temp_connector.width = (int) (x - temp_connector.x); 177 | temp_connector.height = (int) (y - temp_connector.y); 178 | 179 | queue_draw(); 180 | } 181 | 182 | if (rubberband != null) { 183 | rubberband.process_motion(offset_x, offset_y); 184 | 185 | foreach (var node in get_nodes()) { 186 | Graphene.Rect node_bounds, rubberband_bounds, result; 187 | 188 | node.compute_bounds(this, out node_bounds); 189 | rubberband.compute_bounds(this, out rubberband_bounds); 190 | node_bounds.intersection(rubberband_bounds, out result); 191 | node.selected = result.size.width > 0 && result.size.height > 0; 192 | } 193 | } 194 | } 195 | 196 | [GtkCallback] 197 | private void stop_drag(double offset_x, double offset_y) { 198 | double start_x, start_y, x, y; 199 | drag.get_start_point(out start_x, out start_y); 200 | x = start_x + offset_x; 201 | y = start_y + offset_y; 202 | 203 | if (temp_connector != null) { 204 | var widget = pick(x, y, Gtk.PickFlags.DEFAULT); 205 | 206 | if (widget is Socket) { 207 | var socket = (Socket) widget; 208 | 209 | // Relink Sinks 210 | if ( 211 | socket is Sink && clicked_socket != null && 212 | clicked_socket is Sink && 213 | temp_connected_socket is Source 214 | ) { 215 | if (is_suitable_target(socket, temp_connected_socket)) { 216 | clicked_socket.unlink(temp_connected_socket); 217 | socket.link(temp_connected_socket); 218 | } else 219 | temp_connector = null; 220 | 221 | // Link Sockets 222 | } else if ( 223 | socket is Source && temp_connected_socket is Sink || 224 | socket is Sink && temp_connected_socket is Source 225 | ) { 226 | if (is_suitable_target(socket, temp_connected_socket)) 227 | socket.link(temp_connected_socket); 228 | else 229 | temp_connector = null; 230 | } 231 | socket.queue_draw(); 232 | } else { 233 | if ( 234 | temp_connected_socket is Source && 235 | clicked_socket != null && 236 | clicked_socket is Sink 237 | ) { 238 | clicked_socket.unlink(temp_connected_socket); 239 | } 240 | } 241 | 242 | clicked_socket = null; 243 | temp_connected_socket = null; 244 | temp_connector = null; 245 | 246 | queue_draw(); 247 | } 248 | 249 | move_node = null; 250 | resize_node = null; 251 | rubberband?.unparent(); 252 | rubberband = null; 253 | 254 | queue_resize(); 255 | queue_allocate(); 256 | } 257 | 258 | [GtkCallback] 259 | private void open_menu(int n_clicks, double x, double y) { 260 | menu.set_pointing_to({ (int) x, (int) y, 1, 1 }); 261 | menu.popup(); 262 | } 263 | 264 | internal void start_temp_connector(Socket socket) { 265 | clicked_socket = socket; 266 | if (socket is Sink && socket.is_linked()) { 267 | var sink = (Sink) socket; 268 | 269 | temp_connected_socket = sink.sources.last().nth_data(0); 270 | } else { 271 | temp_connected_socket = socket; 272 | } 273 | 274 | Graphene.Point point; 275 | temp_connected_socket.compute_point(this, { 8, 8 }, out point); 276 | 277 | temp_connector = { (int) point.x, (int) point.y, 0, 0 }; 278 | } 279 | 280 | /** 281 | * Add a node to this nodeview 282 | */ 283 | public void add(NodeRenderer node) { 284 | node.set_parent(this); 285 | } 286 | 287 | /** 288 | * Remove a node from this nodeview 289 | */ 290 | public void remove(NodeRenderer node) { 291 | node.unlink_all(); 292 | node.unparent(); 293 | } 294 | 295 | /** 296 | * Determines wheter one socket can be dropped on another 297 | */ 298 | private bool is_suitable_target(Socket from, Socket to) { 299 | // Check whether the sockets have the same type 300 | if (!from.has_same_type(to)) 301 | return false; 302 | // Check if the target would lead to a recursion 303 | // If yes, return the value of allow_recursion. If this 304 | // value is set to true, it's completely fine to have 305 | // a recursive graph 306 | if (to is Source && from is Sink) { 307 | if (!allow_recursion) 308 | if ( 309 | from.node.is_recursive_forward(to.node) || 310 | to.node.is_recursive_backward(from.node) 311 | ) return false; 312 | } 313 | if (to is Sink && from is Source) { 314 | if (!allow_recursion) 315 | if ( 316 | to.node.is_recursive_forward(from.node) || 317 | from.node.is_recursive_backward(to.node) 318 | ) return false; 319 | } 320 | if (to is Sink && from is Sink) { 321 | Source? s = ((Sink) from).sources.last().nth_data(0); 322 | if (s == null) 323 | return false; 324 | if (!allow_recursion) 325 | if ( 326 | to.node.is_recursive_forward(s.node) || 327 | s.node.is_recursive_backward(to.node) 328 | ) return false; 329 | } 330 | // If the from from-target is a sink, check if the 331 | // to target is either a source which does not belong to the own node 332 | // or if the to target is another sink (this is valid as we can 333 | // move a connection from one sink to another 334 | if ( 335 | from is Sink 336 | && ((to is Sink 337 | && to != from) 338 | || (to is Source 339 | && (!to.node.has_socket(from) || allow_recursion))) 340 | ) return true; 341 | 342 | // Check if the from-target is a source. if yes, make sure the 343 | // to-target is a sink and it does not belong to the own node 344 | else if ( 345 | from is Source 346 | && to is Sink 347 | && (!to.node.has_socket(from) || allow_recursion) 348 | ) return true; 349 | 350 | return false; 351 | } 352 | 353 | internal signal void draw_minimap(); 354 | 355 | protected override void snapshot(Gtk.Snapshot snapshot) { 356 | // Snapshot all childs not including 'Rubberband' 357 | for (var child = get_first_child(); child != null; child = child.get_next_sibling()) { 358 | if (child is Rubberband) 359 | continue; 360 | 361 | snapshot_child(child, snapshot); 362 | } 363 | 364 | foreach (var node in get_nodes()) { 365 | Graphene.Point sink_point, source_point; 366 | 367 | foreach (Sink sink in node.get_sinks()) { 368 | 369 | foreach (Source source in sink.sources) { 370 | if ( 371 | temp_connected_socket != null && source == temp_connected_socket 372 | && clicked_socket != null && sink == clicked_socket 373 | ) continue; 374 | 375 | sink.compute_point(this, { 8, 8 }, out sink_point); 376 | source.compute_point(this, { 8, 8 }, out source_point); 377 | 378 | renderer.snapshot_connection( 379 | snapshot, 380 | source, sink, 381 | { 382 | (int) source_point.x, (int) source_point.y, 383 | (int) (sink_point.x - source_point.x), (int) (sink_point.y - source_point.y) 384 | } 385 | ); 386 | } 387 | } 388 | } 389 | draw_minimap(); 390 | if (temp_connector != null) 391 | renderer.snapshot_temp_connector(snapshot, temp_connected_socket, temp_connector); 392 | 393 | // Snapshot rubberband over all widgets & custom drawing 394 | if (rubberband != null) 395 | snapshot_child(rubberband, snapshot); 396 | } 397 | } 398 | 399 | protected class NodeViewLayoutChild : Gtk.LayoutChild { 400 | public int x = 0; 401 | public int y = 0; 402 | 403 | public NodeViewLayoutChild(Gtk.Widget widget, Gtk.LayoutManager layout_manager) { 404 | Object(child_widget: widget, layout_manager: layout_manager); 405 | } 406 | } 407 | 408 | private class NodeViewLayoutManager : Gtk.LayoutManager { 409 | 410 | protected override Gtk.SizeRequestMode get_request_mode(Gtk.Widget widget) { 411 | return Gtk.SizeRequestMode.CONSTANT_SIZE; 412 | } 413 | 414 | protected override void measure(Gtk.Widget widget, Gtk.Orientation orientation, int for_size, out int min, out int pref, out int min_base, out int pref_base) { 415 | var node_view = widget as NodeView; 416 | 417 | int lower_bound = 0; 418 | int upper_bound = 0; 419 | 420 | foreach (var node in node_view.get_nodes()) { 421 | var layout = (NodeViewLayoutChild) get_layout_child(node); 422 | 423 | switch (orientation) { 424 | case Gtk.Orientation.HORIZONTAL: 425 | if (layout.x < 0) 426 | lower_bound = int.min(layout.x, lower_bound); 427 | else 428 | upper_bound = int.max(layout.x + node.get_width(), upper_bound); 429 | 430 | break; 431 | case Gtk.Orientation.VERTICAL: 432 | if (layout.y < 0) 433 | lower_bound = int.min(layout.y, lower_bound); 434 | else 435 | upper_bound = int.max(layout.y + node.get_height(), upper_bound); 436 | 437 | break; 438 | } 439 | } 440 | 441 | min = upper_bound - lower_bound; 442 | pref = upper_bound - lower_bound; 443 | min_base = -1; 444 | pref_base = -1; 445 | } 446 | 447 | protected override void allocate(Gtk.Widget widget, int height, int width, int baseline) { 448 | for (var child = widget.get_first_child(); child != null; child = child.get_next_sibling()) { 449 | if (child is Gtk.Native) 450 | continue; 451 | 452 | int child_width, child_height, _; 453 | 454 | child.measure(Gtk.Orientation.HORIZONTAL, -1, out child_width, out _, out _, out _); 455 | child.measure(Gtk.Orientation.VERTICAL, -1, out child_height, out _, out _, out _); 456 | 457 | var layout = (NodeViewLayoutChild) get_layout_child(child); 458 | 459 | child.queue_allocate(); 460 | child.allocate_size({ 461 | layout.x, layout.y, 462 | child_width, child_height 463 | }, -1); 464 | } 465 | } 466 | 467 | public override Gtk.LayoutChild create_layout_child(Gtk.Widget widget, Gtk.Widget for_child) { 468 | return new NodeViewLayoutChild(for_child, this); 469 | } 470 | } 471 | } 472 | -------------------------------------------------------------------------------- /src/node.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | [GtkTemplate (ui = "/me/paladin/libflow/ui/node.ui")] 4 | public class Node : NodeRenderer { 5 | private List sources = new List(); 6 | private List sinks = new List(); 7 | 8 | private Gtk.Widget _title_widget; 9 | private TitleStyle _title_style = TitleStyle.FLAT; 10 | private Gtk.Widget _content; 11 | private bool _selected; 12 | 13 | [GtkChild] 14 | private unowned Gtk.PopoverMenu menu; 15 | [GtkChild] 16 | private unowned Gtk.Box main_box; 17 | [GtkChild] 18 | private unowned Gtk.Box title_box; 19 | [GtkChild] 20 | private unowned Gtk.Box sink_box; 21 | [GtkChild] 22 | private unowned Gtk.Box source_box; 23 | [GtkChild] 24 | private unowned Gtk.Box content_box; 25 | public override bool selected { 26 | get { return _selected; } 27 | set { 28 | if (value) 29 | set_state_flags(Gtk.StateFlags.SELECTED, false); 30 | else 31 | unset_state_flags(Gtk.StateFlags.SELECTED); 32 | 33 | _selected = value; 34 | } 35 | } 36 | public Gtk.Widget title_widget { 37 | get { return _title_widget; } 38 | set { 39 | if (_title_widget == value) 40 | return; 41 | 42 | _title_widget?.unparent(); 43 | _title_widget = value; 44 | 45 | if (_title_widget != null) 46 | title_box.append(_title_widget); 47 | } 48 | } 49 | public TitleStyle title_style { 50 | get { return _title_style; } 51 | set { 52 | _title_style = value; 53 | title_box.css_classes = value.get_css_styles(); 54 | } 55 | } 56 | public Gtk.Widget content { 57 | get { return _content; } 58 | set { 59 | if (_content == value) 60 | return; 61 | 62 | content_box.visible = false; 63 | _content?.unparent(); 64 | _content = value; 65 | 66 | if (_content != null) { 67 | content_box.visible = true; 68 | content_box.append(_content); 69 | } 70 | } 71 | } 72 | 73 | static construct { 74 | set_css_name("node"); 75 | } 76 | 77 | construct { 78 | var action_group = new SimpleActionGroup(); 79 | var delete_action = new SimpleAction("delete", null); 80 | delete_action.activate.connect(() => { 81 | @delete(); 82 | }); 83 | action_group.add_action(delete_action); 84 | 85 | var unlink_action = new SimpleAction("unlink-all", null); 86 | unlink_action.activate.connect(() => { 87 | unlink_all(); 88 | }); 89 | action_group.add_action(unlink_action); 90 | var test_action = new SimpleAction("test", null); 91 | 92 | insert_action_group("node", action_group); 93 | 94 | set_layout_manager(new Gtk.BinLayout()); 95 | 96 | notify["x"].connect(update_position); 97 | notify["y"].connect(update_position); 98 | notify["parent"].connect(update_position); 99 | } 100 | 101 | public void set_label_name(string name, Gtk.Align halign = Gtk.Align.CENTER, bool bold = true) { 102 | var label = new Gtk.Label(name) { halign = halign }; 103 | 104 | if (bold) 105 | label.set_markup(@"$name"); 106 | 107 | title_widget = label; 108 | } 109 | 110 | public virtual void @delete() { 111 | var node_view = parent as NodeView; 112 | 113 | node_view.remove(this); 114 | } 115 | 116 | /** 117 | * {@inheritDoc} 118 | */ 119 | public override void dispose() { 120 | main_box.unparent(); 121 | menu.unparent(); 122 | base.dispose(); 123 | } 124 | 125 | public new void sink_added(Sink sink) { 126 | var box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0); 127 | box.spacing = 5; 128 | 129 | box.append(sink); 130 | box.append(sink.label); 131 | 132 | sink_box.append(box); 133 | } 134 | 135 | public new void source_added(Source source) { 136 | var box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0); 137 | box.spacing = 5; 138 | 139 | box.append(source.label); 140 | box.append(source); 141 | 142 | source_box.append(box); 143 | } 144 | 145 | [GtkCallback] 146 | private void begin_drag(double x, double y) { 147 | var picked_widget = pick(x,y, Gtk.PickFlags.NON_TARGETABLE); 148 | 149 | if (picked_widget != this && !can_drag(picked_widget)) 150 | return; 151 | 152 | Gdk.Rectangle resize_area = { get_width() - 8, get_height() - 8, 8, 8 }; 153 | var node_view = parent as NodeView; 154 | 155 | if (resize_area.contains_point((int) x, (int) y)) { 156 | node_view.resize_node = this; 157 | resize_start_width = get_width(); 158 | resize_start_height = get_height(); 159 | } else { 160 | node_view.move_node = this; 161 | } 162 | 163 | click_offset_x = x; 164 | click_offset_y = y; 165 | } 166 | 167 | [GtkCallback] 168 | private void open_menu(int n_clicks, double x, double y) { 169 | menu.set_pointing_to({ (int) x, (int) y, 1, 1 }); 170 | menu.popup(); 171 | } 172 | 173 | /** 174 | * {@inheritDoc} 175 | */ 176 | public new void set_parent(Gtk.Widget parent) { 177 | if (!(parent is NodeView)) { 178 | warning("Trying to add a Flow.Node to something that is not a Flow.NodeView!"); 179 | return; 180 | } 181 | base.set_parent(parent); 182 | } 183 | 184 | /** 185 | * Add the given {@link Source} to this Node 186 | */ 187 | public override void add_source(Source source) { 188 | if (source.node != null) 189 | return; // This Source is already bound 190 | 191 | if (sources.index(source) != -1) 192 | return; // This node already has this source 193 | 194 | sources.append(source); 195 | source.node = this; 196 | source_added(source); 197 | } 198 | 199 | /** 200 | * Add the given {@link Sink} to this Node 201 | */ 202 | public override void add_sink(Sink sink) { 203 | if (sink.node != null) 204 | return; // This Sink is already bound 205 | 206 | if (sinks.index(sink) != -1) 207 | return; //This node already has this sink 208 | 209 | sinks.append(sink); 210 | sink.node = this; 211 | sink_added(sink); 212 | } 213 | 214 | /** 215 | * Remove the given {@link Source} from this Node 216 | */ 217 | public override void remove_source(Source source) { 218 | if (sources.index(source) == -1) 219 | return; // This node doesn't have this source 220 | 221 | sources.remove(source); 222 | source.node = null; 223 | source_removed(source); 224 | } 225 | 226 | /** 227 | * Remove the given {@link Sink} from this Node 228 | */ 229 | public override void remove_sink(Sink sink) { 230 | if (sinks.index(sink) == -1) 231 | return; // This node doesn't have this sink 232 | 233 | sinks.remove(sink); 234 | sink.node = null; 235 | sink_removed(sink); 236 | } 237 | 238 | /** 239 | * Returns true if the given {@link Sink} is one of this Node's sinks. 240 | */ 241 | public override bool has_sink(Sink sink) { 242 | return sinks.index(sink) != -1; 243 | } 244 | 245 | /** 246 | * Returns true if the given {@link Source} is one of this Node's sources. 247 | */ 248 | public override bool has_source(Source source) { 249 | return sources.index(source) != -1; 250 | } 251 | 252 | /** 253 | * Returns true if the given {@link Socket} is one of this Node's sockets. 254 | */ 255 | public override bool has_socket(Socket socket) { 256 | if (socket is Source) 257 | return has_source(socket as Source); 258 | else 259 | return has_sink(socket as Sink); 260 | } 261 | 262 | /** 263 | * Searches this Node's {@link Socket}s for a Socket with the given name. 264 | * If there is any, it will be returned. Else, null will be returned 265 | */ 266 | public override Socket? get_socket(string name) { 267 | foreach (Sink s in sinks) 268 | if (s.name == name) 269 | return s; 270 | foreach (Source s in sources) 271 | if (s.name == name) 272 | return s; 273 | return null; 274 | } 275 | 276 | /** 277 | * Returns the sources of this node 278 | */ 279 | public override unowned List get_sources() { 280 | return sources; 281 | } 282 | 283 | /** 284 | * Returns the sinks of this node 285 | */ 286 | public override unowned List get_sinks() { 287 | return sinks; 288 | } 289 | 290 | /** 291 | * This method checks whether a connection from the given from-Node 292 | * to this Node would lead to a recursion in the direction source -> sink 293 | */ 294 | public override bool is_recursive_forward(NodeRenderer from, bool initial = true) { 295 | if (!initial && this == from) 296 | return true; 297 | 298 | foreach (Source source in get_sources()) 299 | foreach (Sink sink in source.sinks) 300 | if (sink.node.is_recursive_forward(from, false)) 301 | return true; 302 | 303 | return false; 304 | } 305 | 306 | /** 307 | * This method checks whether a connection from the given from-Node 308 | * to this Node would lead to a recursion in the direction sink -> source 309 | */ 310 | public override bool is_recursive_backward(NodeRenderer from, bool initial = true) { 311 | if (!initial && this == from) 312 | return true; 313 | 314 | foreach (Sink sink in sinks) 315 | foreach (Source source in sink.sources) 316 | if (source.node.is_recursive_backward(from, false)) 317 | return true; 318 | 319 | return false; 320 | } 321 | 322 | /** 323 | * Gets all neighbor nodes that this node is connected to 324 | */ 325 | public override List get_neighbors() { 326 | var result = new List(); 327 | 328 | foreach (Source source in get_sources()) 329 | foreach (Sink sink in source.sinks) 330 | if (sink.node != null && result.index(sink.node) == -1) 331 | result.append(sink.node); 332 | 333 | foreach (Sink sink in get_sinks()) 334 | foreach (Source source in sink.sources) 335 | if (source.node != null && result.index(source.node) == -1) 336 | result.append(source.node); 337 | 338 | return result; 339 | } 340 | 341 | /** 342 | * Returns true if the given node is directly connected 343 | * to this node 344 | */ 345 | public override bool is_neighbor(NodeRenderer node) { 346 | return get_neighbors().index(node) != -1; 347 | } 348 | 349 | /** 350 | * Disconnect all connections from and to this node 351 | */ 352 | public override void unlink_all() { 353 | foreach (Source source in sources) 354 | source.unlink_all(); 355 | 356 | foreach (Sink sink in sinks) 357 | sink.unlink_all(); 358 | } 359 | 360 | private bool can_drag(Gtk.Widget widget) { 361 | if (!has_gestures(widget)) { 362 | for (var parent = widget.parent; parent != this; parent = parent.parent) { 363 | if (has_gestures(parent)) { 364 | return false; 365 | } 366 | } 367 | return true; 368 | } 369 | return false; 370 | } 371 | 372 | private bool has_gestures(Gtk.Widget widget) { 373 | var list = widget.observe_controllers(); 374 | for (int i = 0; i < list.get_n_items(); i++) 375 | if (list.get_item(i) is Gtk.Gesture) 376 | return true; 377 | 378 | return false; 379 | } 380 | 381 | private void update_position() { 382 | var node_view = parent as NodeView; 383 | 384 | if (node_view == null) 385 | return; 386 | 387 | var layout = node_view.get_layout(this); 388 | 389 | layout.x = x; 390 | layout.y = y; 391 | } 392 | } 393 | 394 | public abstract class NodeRenderer : Gtk.Widget { 395 | /** 396 | * Expresses wheter this node is marked via rubberband selection 397 | */ 398 | public virtual bool selected { get; set; } 399 | /** 400 | * Determines wheter the user be allowed to remove the node. Otherwise 401 | * the node can only be removed programmatically 402 | */ 403 | public bool deletable { get; set; default = true; } 404 | /** 405 | * Determines wheter the user be allowed to remove the node. Otherwise 406 | * the node can only be removed programmatically 407 | */ 408 | public bool resizable { get; set; default = true; } 409 | public Gdk.RGBA? highlight_color { get; set; default = null; } 410 | public int x { get; set; default = 0; } 411 | public int y { get; set; default = 0; } 412 | /** 413 | * Click offset: x coordinate 414 | * 415 | * Holds the offset-position relative to the origin of the 416 | * node at which this node has been clicked the last time. 417 | */ 418 | public double click_offset_x { get; protected set; default = 0; } 419 | /** 420 | * Click offset: y coordinate 421 | * 422 | * Holds the offset-position relative to the origin of the 423 | * node at which this node has been clicked the last time. 424 | */ 425 | public double click_offset_y { get; protected set; default = 0; } 426 | 427 | /** 428 | * Resize start width 429 | * 430 | * Hold the original width of the node when the last resize process 431 | * had been started 432 | */ 433 | public double resize_start_width { get; protected set; default = 0; } 434 | /** 435 | * Resize start height 436 | * 437 | * Hold the original height of the node when the last resize process 438 | * had been started 439 | */ 440 | public double resize_start_height { get; protected set; default = 0; } 441 | /** 442 | * This signal is being triggered when a {@link Sink} is added to this Node 443 | */ 444 | public signal void sink_added(Sink sink); 445 | /** 446 | * This signal is being triggered when a {@link Source} is added to this Node 447 | */ 448 | public signal void source_added(Source source); 449 | /** 450 | * This signal is being triggered when a {@link Sink} is removed from this Node 451 | */ 452 | public signal void sink_removed(Sink sink); 453 | /** 454 | * This signal is being triggered when a {@link Source} is removed from this Node 455 | */ 456 | public signal void source_removed(Source sink); 457 | 458 | /** 459 | * Implementations should destroy all connections of this Node's {@link Sink}s 460 | * and {@link Source}s when this method is executed 461 | */ 462 | public abstract void unlink_all(); 463 | /** 464 | * Determines whether the given from-{@link Node} can be found if we 465 | * recursively follow all nodes that are connected to this node's {@link Source}s 466 | */ 467 | public abstract bool is_recursive_forward(NodeRenderer from, bool initial = false); 468 | /** 469 | * Determines whether the given from-{@link Node} can be found if we 470 | * recursively follow all nodes that are connected to this node's {@link Sink}s 471 | */ 472 | public abstract bool is_recursive_backward(NodeRenderer from, bool initial = false); 473 | /** 474 | * Implementations should return the {@link Socket} with the given name if they contain 475 | * any. If not, return null. 476 | */ 477 | public abstract Socket? get_socket(string name); 478 | /** 479 | * Implementations should return true if the given {@link Socket} has been 480 | * assigned to this node 481 | */ 482 | public abstract bool has_socket(Socket socket); 483 | /** 484 | * Return a {@link GLib.List} of this Node's {@link Source}s 485 | */ 486 | public abstract unowned List get_sources(); 487 | /** 488 | * Return a {@link GLib.List} of this Node's {@link Sink}s 489 | */ 490 | public abstract unowned List get_sinks(); 491 | /** 492 | * Returns the Nodes that this Node is connected to 493 | */ 494 | public abstract List get_neighbors(); 495 | /** 496 | * Returns true if the given node is directly connected to this node 497 | */ 498 | public abstract bool is_neighbor(NodeRenderer node); 499 | /** 500 | * Assign a {@link Source} to this Node 501 | */ 502 | public abstract void add_source(Source source); 503 | /** 504 | * Remove a {@link Source} from this Node 505 | */ 506 | public abstract void remove_source(Source source); 507 | /** 508 | * Return true if the supplied {@link Source} is assigned to this Node 509 | */ 510 | public abstract bool has_source(Source source); 511 | /** 512 | * Assign a {@link Sink} to this Node 513 | */ 514 | public abstract void add_sink(Sink sink); 515 | /** 516 | * Return true if the supplied {@link Sink} is assigned to this Node 517 | */ 518 | public abstract bool has_sink(Sink sink); 519 | /** 520 | * Remove a {@link Sink} from this Node 521 | */ 522 | public abstract void remove_sink(Sink sink); 523 | } 524 | } 525 | -------------------------------------------------------------------------------- /src/renderers/connection-renderer.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | public class ConnectionRenderer : Object { 4 | 5 | public virtual void snapshot_temp_connector(Gtk.Snapshot snapshot, Socket socket, Gdk.Rectangle rect) { 6 | snapshot.append_stroke( 7 | build_curve(rect), 8 | new Gsk.Stroke(socket.line_width), 9 | socket.color 10 | ); 11 | } 12 | 13 | public virtual void snapshot_connection(Gtk.Snapshot snapshot, Socket start, Socket end, Gdk.Rectangle rect) { 14 | var path = build_curve(rect); 15 | var stroke = new Gsk.Stroke(start.line_width); 16 | Graphene.Rect bounds; 17 | path.get_stroke_bounds(stroke, out bounds); 18 | 19 | snapshot.push_stroke(path, stroke); 20 | snapshot.append_linear_gradient(bounds, { rect.x, rect.y }, {rect.width + rect.x, rect.height + rect.y}, {{0, start.color}, {1, end.color}}); 21 | snapshot.pop(); 22 | } 23 | 24 | public virtual Gsk.Path build_curve(Gdk.Rectangle rect) { 25 | var builder = new Gsk.PathBuilder(); 26 | 27 | builder.move_to(rect.x, rect.y); 28 | if (rect.width > 0) 29 | builder.rel_cubic_to(rect.width / 3, 0, 2 * rect.width / 3, rect.height, rect.width, rect.height); 30 | else 31 | builder.rel_cubic_to(-rect.width / 3, 0, 1.3F * rect.width, rect.height, rect.width, rect.height); 32 | 33 | return builder.to_path(); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/renderers/socket-renderer.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | public class SocketRenderer : Object { 4 | 5 | public virtual void snapshot_socket(Gtk.Snapshot snapshot, Socket socket) { 6 | 7 | var cairo = snapshot.append_cairo(Graphene.Rect().init(0, 0, 16, 16)); 8 | 9 | cairo.save(); 10 | 11 | render_background(cairo, socket); 12 | 13 | cairo.restore(); 14 | 15 | if (socket.is_linked()) { 16 | cairo.save(); 17 | 18 | render_linked(cairo, socket, socket.color); 19 | 20 | cairo.restore(); 21 | } 22 | } 23 | 24 | public virtual void render_background(Cairo.Context cairo, Socket socket) { 25 | cairo.set_source_rgba(0.5f, 0.5f, 0.5f, 0.5f); 26 | cairo.arc(8, 8, 8, 0, 2 * Math.PI); 27 | cairo.fill(); 28 | } 29 | 30 | public virtual void render_linked(Cairo.Context cairo, Socket socket, Gdk.RGBA color) { 31 | cairo.set_source_rgba( 32 | color.red, 33 | color.green, 34 | color.blue, 35 | color.alpha 36 | ); 37 | cairo.arc(8, 8, 4, 0.0, 2 * Math.PI); 38 | cairo.fill(); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/rubberband.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | protected class Rubberband : Gtk.Widget { 4 | private NodeViewLayoutChild layout; 5 | 6 | public int start_x { get; construct set; } 7 | public int start_y { get; construct set; } 8 | 9 | static construct { 10 | set_css_name("rubberband"); 11 | } 12 | 13 | public Rubberband(NodeView parent, double x, double y) { 14 | set_parent(parent); 15 | 16 | layout = parent.get_layout(this); 17 | layout.x = start_x = (int) x; 18 | layout.y = start_y = (int) y; 19 | } 20 | 21 | public void process_motion(double x, double y) { 22 | Gdk.Rectangle selection = { 23 | start_x, start_y, 24 | (int) x, (int) y 25 | }; 26 | 27 | if (selection.width < 0) { 28 | selection.width *= -1; 29 | selection.x -= selection.width; 30 | } 31 | 32 | if (selection.height < 0) { 33 | selection.height *= -1; 34 | selection.y -= selection.height; 35 | } 36 | 37 | layout.x = selection.x; 38 | layout.y = selection.y; 39 | set_size_request(selection.width, selection.height); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/sink.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | public class Sink : Socket { 4 | private List _sources = new List(); 5 | /** 6 | * The {@link Source}s that this Sink is currently connected to 7 | */ 8 | public List sources { get { return _sources; } } 9 | /** 10 | * Defines how many sources can be connected to this sink 11 | * 12 | * Setting this variable to a lower value than the current 13 | * amount of connected sources will have no further effects 14 | * than not allowing more connections. 15 | */ 16 | public uint max_sources { get; set; default = 1; } 17 | 18 | construct { 19 | add_css_class("sink"); 20 | } 21 | 22 | /** 23 | * Creates a new Sink with type of given value {@link GLib.Value} 24 | */ 25 | public Sink(Value @value) { 26 | value_type = @value.get_gtype(); 27 | } 28 | 29 | /** 30 | * Creates a new Sink with given type {@link GLib.Type} 31 | */ 32 | public Sink.with_type(Type type) { 33 | value_type = type; 34 | } 35 | 36 | protected void add_source(Source source) { 37 | if (value_type == source.value_type) { 38 | _sources.append(source); 39 | 40 | changed(); 41 | source.changed.connect(do_source_changed); 42 | } 43 | } 44 | 45 | protected void remove_source(Source source) { 46 | if (_sources.index(source) != -1) 47 | _sources.remove(source); 48 | 49 | if (source.is_linked_to(this)) { 50 | source.unlink(this); 51 | 52 | unlinked(source, _sources.length() == 0); 53 | } 54 | } 55 | 56 | /** 57 | * Returns true if this Source is connected to one or more Sinks 58 | */ 59 | public override bool is_linked() { 60 | return _sources.length() > 0; 61 | } 62 | 63 | /** 64 | * Returns true if this Source is connected to the given Sink 65 | */ 66 | public override bool is_linked_to(Socket socket) { 67 | if (!(socket is Source)) 68 | return false; 69 | 70 | return _sources.index((Source) socket) != -1; 71 | } 72 | 73 | /** 74 | * Connect to the given {@link Socket} 75 | */ 76 | public override void link(Socket socket) { 77 | if (is_linked_to(socket)) 78 | return; 79 | 80 | if (!before_linking(this, socket)) 81 | return; 82 | 83 | // Switching 84 | if (_sources.length() + 1 > max_sources && sources.length() > 0) 85 | unlink(sources.nth_data(sources.length() - 1)); 86 | 87 | if (socket is Source) { 88 | add_source((Source) socket); 89 | changed(); 90 | socket.link(this); 91 | 92 | linked(socket); 93 | } 94 | } 95 | 96 | /** 97 | * Disconnect from the given {@link Socket} 98 | */ 99 | public override void unlink(Socket socket) { 100 | if (!is_linked_to(socket)) 101 | return; 102 | 103 | if (socket is Source) { 104 | remove_source((Source) socket); 105 | do_source_changed(); 106 | socket.unlinked(this, sources.length() == 0); 107 | socket.changed.disconnect(do_source_changed); 108 | changed(); 109 | } 110 | } 111 | 112 | /** 113 | * Disconnects the Sink from all {@link Source}s that supply 114 | * it with data. 115 | */ 116 | public override void unlink_all() { 117 | // Copy is required to avoid crash 118 | foreach (var source in _sources.copy()) 119 | if (source != null) 120 | unlink(source); 121 | } 122 | 123 | private void do_source_changed(Value? source_value = null, string? flow_id = null) { 124 | changed(source_value, flow_id); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/socket.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | public abstract class Socket : Gtk.Widget { 4 | private Gtk.GestureClick click; 5 | 6 | public SocketRenderer renderer { get; set; } 7 | public Gtk.Widget label { get; private set; } 8 | /** 9 | * The name that will be rendered for this socket 10 | */ 11 | public string? description { get; set; } 12 | /** 13 | * Color of this socket 14 | */ 15 | public Gdk.RGBA color { get; set; default = { 0, 0, 0, 1 }; } 16 | /** 17 | * Line width of this socket 18 | * Be aware that only {@link Source}s dictate the line width of the 19 | * connections in default {@link ConnectionRenderer}. If this Socket holds a {@link Sink} it 20 | * will have no visible effect. 21 | */ 22 | public float line_width { get; set; default = 2; } 23 | /** 24 | * Determines whether this socket is highlighted 25 | * this is usually triggered when the mouse hovers over it 26 | */ 27 | public bool highlight { get; set; } 28 | 29 | /** 30 | * Determines whether this socket is active 31 | */ 32 | public bool active { get; set; } 33 | /** 34 | * A reference to the node this Socket resides in 35 | */ 36 | public weak NodeRenderer? node { get; set; } 37 | 38 | /** 39 | * The type that has been set to this socket 40 | */ 41 | public Type value_type { get; construct set; } 42 | 43 | static construct { 44 | set_css_name("socket"); 45 | } 46 | 47 | construct { 48 | renderer = new SocketRenderer(); 49 | 50 | linked.connect(queue_draw); 51 | unlinked.connect(queue_draw); 52 | 53 | label = new Gtk.Label(name); 54 | 55 | valign = Gtk.Align.CENTER; 56 | halign = Gtk.Align.CENTER; 57 | 58 | click = new Gtk.GestureClick(); 59 | add_controller(click); 60 | click.pressed.connect(press_button); 61 | 62 | notify["name"].connect(() => { 63 | if (label is Gtk.Label) 64 | ((Gtk.Label) label).set_text(name); 65 | }); 66 | } 67 | 68 | /** 69 | * This signal is being triggered, when there is a connection being established 70 | * from or to this Socket. 71 | */ 72 | public signal void linked(Socket socket); 73 | 74 | /** 75 | * This signal is being triggered, before a connection is made 76 | * between two sockets. If the implementor returns false, the 77 | * connection is not being made. 78 | * IMPORTANT: Connect to this signal with connect_after 79 | * otherwise this default handler will be called after the 80 | * signal you implemented, thus always returning true, 81 | * rendering your code ineffective. 82 | */ 83 | public virtual signal bool before_linking(Socket self, Socket other) { 84 | return true; 85 | } 86 | 87 | /** 88 | * This signal is being triggered, when there is a connection being removed 89 | * from or to this Socket. If this the last connection of the socket, the 90 | * boolean parameter last will be set to true. 91 | */ 92 | public signal void unlinked(Socket socket, bool last); 93 | 94 | /** 95 | * Triggers when the value of this socket changes 96 | */ 97 | public signal void changed(Value? value = null, string? flow_id = null); 98 | 99 | /** 100 | * Implementations should return true if this socket has at least one 101 | * connection to another socket 102 | */ 103 | public abstract bool is_linked(); 104 | 105 | /** 106 | * Implementations should return true if this socket is connected 107 | * to the supplied socket 108 | */ 109 | public abstract bool is_linked_to(Socket socket); 110 | 111 | /** 112 | * Connect this {@link Socket} to other {@link Socket} 113 | */ 114 | public abstract void link(Socket socket); 115 | /** 116 | * Disconnect this {@link Socket} from other {@link Socket} 117 | */ 118 | public abstract void unlink(Socket socket); 119 | /** 120 | * Disconnect this {@link Socket} from all {@link Socket}s it is connected to 121 | */ 122 | public abstract void unlink_all(); 123 | 124 | /** 125 | * Tries to resolve this Socket's value-type to a displayable string 126 | */ 127 | public virtual string determine_typestring() { 128 | return value_type.name(); 129 | } 130 | 131 | /** 132 | * Returs true if this and the supplied socket have 133 | * same type 134 | */ 135 | public virtual bool has_same_type(Socket other) { 136 | return value_type == other.value_type; 137 | } 138 | 139 | private NodeView? get_nodeview() { 140 | var parent = parent; 141 | 142 | while (true) { 143 | if (parent == null) 144 | return null; 145 | 146 | else if (parent is NodeView) 147 | return (NodeView) parent; 148 | 149 | else 150 | parent = parent.parent; 151 | } 152 | } 153 | 154 | protected override void snapshot(Gtk.Snapshot snapshot) { 155 | base.snapshot(snapshot); 156 | 157 | renderer.snapshot_socket(snapshot, this); 158 | } 159 | 160 | private void press_button(int n_clicked, double x, double y) { 161 | var node_view = get_nodeview(); 162 | 163 | if (node_view == null) { 164 | warning("Socket could not process button press: no nodeview"); 165 | return; 166 | } 167 | 168 | node_view.start_temp_connector(this); 169 | node_view.queue_allocate(); 170 | } 171 | 172 | protected override void measure(Gtk.Orientation o, int for_size, out int min, out int pref, out int min_base, out int pref_base) { 173 | min = 16; 174 | pref = 16; 175 | min_base = -1; 176 | pref_base = -1; 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/source.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | public class Source : Socket { 4 | private List _sinks = new List(); 5 | private Value? last_value; 6 | /** 7 | * The {@link Sink}s that this Source is connected to 8 | */ 9 | public List sinks { get { return _sinks; } } 10 | 11 | construct { 12 | add_css_class("source"); 13 | } 14 | 15 | /** 16 | * Creates a new Source. Supply an arbitrary {@link GLib.Value}. 17 | * This initial value's type will determine this Source's type. 18 | */ 19 | public Source(Value @value) { 20 | value_type = @value.get_gtype(); 21 | } 22 | 23 | public Source.with_type(Type type) { 24 | value_type = type; 25 | } 26 | 27 | protected void add_sink(Sink sink) { 28 | if (value_type == sink.value_type) { 29 | _sinks.append(sink); 30 | 31 | changed(); 32 | } 33 | } 34 | 35 | protected void remove_sink(Sink sink) { 36 | if (_sinks.index(sink) != -1) 37 | _sinks.remove(sink); 38 | 39 | if (sink.is_linked_to(this)) { 40 | sink.unlink(this); 41 | 42 | unlinked(sink, _sinks.length() == 0); 43 | } 44 | } 45 | 46 | /** 47 | * Returns true if this Source is connected to one or more Sinks 48 | */ 49 | public override bool is_linked() { 50 | return _sinks.length() > 0; 51 | } 52 | 53 | /** 54 | * Returns true if this Source is connected to the given Sink 55 | */ 56 | public override bool is_linked_to(Socket socket) { 57 | if (!(socket is Sink)) 58 | return false; 59 | 60 | return _sinks.index((Sink) socket) != -1; 61 | } 62 | 63 | /** 64 | * Connect to the given {@link Socket} 65 | */ 66 | public override void link(Socket socket) { 67 | if (!before_linking(this, socket)) 68 | return; 69 | 70 | if (socket is Sink) { 71 | if (is_linked_to(socket)) 72 | return; 73 | 74 | add_sink((Sink) socket); 75 | socket.link(this); 76 | changed(last_value); 77 | 78 | linked(socket); 79 | } 80 | } 81 | 82 | /** 83 | * Disconnect from the given {@link Socket} 84 | */ 85 | public override void unlink(Socket socket) { 86 | if (!is_linked_to(socket)) 87 | return; 88 | 89 | if (socket is Sink) 90 | remove_sink((Sink) socket); 91 | } 92 | 93 | /** 94 | * Disconnect from any {@link Socket} that this Source is connected to 95 | */ 96 | public override void unlink_all() { 97 | foreach (var sink in _sinks.copy()) 98 | if (sink != null) 99 | unlink(sink); 100 | } 101 | 102 | /** 103 | * Set the value of this Source 104 | */ 105 | public void set_value(Value? @value, string? flow_id = null) { 106 | if (@value != null && value_type == @value.type()) { 107 | last_value = @value; 108 | 109 | changed(@value, flow_id); 110 | } 111 | } 112 | 113 | /** 114 | * Returns the last value passed throguh this source 115 | */ 116 | public new Value? get_last_value() { 117 | return last_value; 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/title-style.vala: -------------------------------------------------------------------------------- 1 | namespace Flow { 2 | 3 | public enum TitleStyle { 4 | FLAT, 5 | SHADOW, 6 | SEPARATOR; 7 | 8 | public string[] get_css_styles() { 9 | switch (this) { 10 | case TitleStyle.FLAT: 11 | return {}; 12 | case TitleStyle.SHADOW: 13 | return { "shadow" }; 14 | case TitleStyle.SEPARATOR: 15 | return { "separator" }; 16 | default: 17 | assert_not_reached(); 18 | } 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /tests/meson.build: -------------------------------------------------------------------------------- 1 | unit_tests = { 2 | 'NodeView': files( 3 | 'node-view-test.vala' 4 | ) 5 | } 6 | 7 | unit_tests_deps = [ 8 | dependency('gtk4'), 9 | lib_dependency 10 | ] 11 | 12 | foreach name, test_sources : unit_tests 13 | test(name, executable(name, sources: test_sources, dependencies: unit_tests_deps)) 14 | endforeach 15 | -------------------------------------------------------------------------------- /tests/node-view-test.vala: -------------------------------------------------------------------------------- 1 | int main(string[] args) { 2 | Gtk.init(); 3 | Test.init(ref args); 4 | 5 | Test.add_func("/libflow/node-view/add", () => { 6 | var node_view = new Flow.NodeView(); 7 | var node = new Flow.Node(); 8 | 9 | node_view.add(node); 10 | 11 | var nodes = node_view.get_nodes(); 12 | 13 | assert(nodes.length() == 1); 14 | assert(nodes.nth_data(0) == node); 15 | }); 16 | 17 | return Test.run(); 18 | } 19 | --------------------------------------------------------------------------------