1"""Draws DAG in ASCII.""" 2 3from __future__ import unicode_literals 4from __future__ import print_function 5 6import sys 7import math 8 9from grandalf.graphs import Vertex, Edge, Graph 10from grandalf.layouts import SugiyamaLayout 11from grandalf.routing import route_with_lines, EdgeViewer 12 13 14class VertexViewer(object): 15 """Class to define vertex box boundaries that will be accounted for during 16 graph building by grandalf. 17 18 Args: 19 name (str): name of the vertex. 20 """ 21 22 HEIGHT = 3 # top and bottom box edges + text 23 24 def __init__(self, name): 25 # pylint: disable=invalid-name 26 self._h = self.HEIGHT # top and bottom box edges + text 27 self._w = len(name) + 2 # right and left bottom edges + text 28 29 @property 30 def h(self): # pylint: disable=invalid-name 31 """Height of the box.""" 32 return self._h 33 34 @property 35 def w(self): # pylint: disable=invalid-name 36 """Width of the box.""" 37 return self._w 38 39 40class AsciiCanvas(object): 41 """Class for drawing in ASCII. 42 43 Args: 44 cols (int): number of columns in the canvas. Should be > 1. 45 lines (int): number of lines in the canvas. Should be > 1. 46 """ 47 48 TIMEOUT = 10 49 50 def __init__(self, cols, lines): 51 assert cols > 1 52 assert lines > 1 53 54 self.cols = cols 55 self.lines = lines 56 57 self.canvas = [[" "] * cols for l in range(lines)] 58 59 def draw(self): 60 """Draws ASCII canvas on the screen.""" 61 if sys.stdout.isatty(): # pragma: no cover 62 from asciimatics.screen import Screen 63 64 Screen.wrapper(self._do_draw) 65 else: 66 for line in self.canvas: 67 print("".join(line)) 68 69 def _do_draw(self, screen): # pragma: no cover 70 # pylint: disable=too-many-locals 71 # pylint: disable=too-many-branches, too-many-statements 72 from dvc.system import System 73 from asciimatics.event import KeyboardEvent 74 75 offset_x = 0 76 offset_y = 0 77 smaxrow, smaxcol = screen.dimensions 78 assert smaxrow > 1 79 assert smaxcol > 1 80 smaxrow -= 1 81 smaxcol -= 1 82 83 if self.lines + 1 > smaxrow: 84 max_y = self.lines + 1 - smaxrow 85 else: 86 max_y = 0 87 88 if self.cols + 1 > smaxcol: 89 max_x = self.cols + 1 - smaxcol 90 else: 91 max_x = 0 92 93 while True: 94 for y in range(smaxrow + 1): 95 y_index = offset_y + y 96 line = [] 97 for x in range(smaxcol + 1): 98 x_index = offset_x + x 99 if ( 100 len(self.canvas) > y_index 101 and len(self.canvas[y_index]) > x_index 102 ): 103 line.append(self.canvas[y_index][x_index]) 104 else: 105 line.append(" ") 106 assert len(line) == (smaxcol + 1) 107 screen.print_at("".join(line), 0, y) 108 109 screen.refresh() 110 111 # NOTE: get_event() doesn't block by itself, 112 # so we have to do the blocking ourselves. 113 # 114 # NOTE: using this workaround while waiting for PR [1] 115 # to get merged and released. After that need to adjust 116 # asciimatics version requirements. 117 # 118 # [1] https://github.com/peterbrittain/asciimatics/pull/188 119 System.wait_for_input(self.TIMEOUT) 120 121 event = screen.get_event() 122 if not isinstance(event, KeyboardEvent): 123 continue 124 125 k = event.key_code 126 if k == screen.KEY_DOWN or k == ord("s"): 127 offset_y += 1 128 elif k == screen.KEY_PAGE_DOWN or k == ord("S"): 129 offset_y += smaxrow 130 elif k == screen.KEY_UP or k == ord("w"): 131 offset_y -= 1 132 elif k == screen.KEY_PAGE_UP or k == ord("W"): 133 offset_y -= smaxrow 134 elif k == screen.KEY_RIGHT or k == ord("d"): 135 offset_x += 1 136 elif k == ord("D"): 137 offset_x += smaxcol 138 elif k == screen.KEY_LEFT or k == ord("a"): 139 offset_x -= 1 140 elif k == ord("A"): 141 offset_x -= smaxcol 142 elif k == ord("q") or k == ord("Q"): 143 break 144 145 if offset_y > max_y: 146 offset_y = max_y 147 elif offset_y < 0: 148 offset_y = 0 149 150 if offset_x > max_x: 151 offset_x = max_x 152 elif offset_x < 0: 153 offset_x = 0 154 155 def point(self, x, y, char): 156 """Create a point on ASCII canvas. 157 158 Args: 159 x (int): x coordinate. Should be >= 0 and < number of columns in 160 the canvas. 161 y (int): y coordinate. Should be >= 0 an < number of lines in the 162 canvas. 163 char (str): character to place in the specified point on the 164 canvas. 165 """ 166 assert len(char) == 1 167 assert x >= 0 168 assert x < self.cols 169 assert y >= 0 170 assert y < self.lines 171 172 self.canvas[y][x] = char 173 174 def line(self, x0, y0, x1, y1, char): 175 """Create a line on ASCII canvas. 176 177 Args: 178 x0 (int): x coordinate where the line should start. 179 y0 (int): y coordinate where the line should start. 180 x1 (int): x coordinate where the line should end. 181 y1 (int): y coordinate where the line should end. 182 char (str): character to draw the line with. 183 """ 184 # pylint: disable=too-many-arguments, too-many-branches 185 if x0 > x1: 186 x1, x0 = x0, x1 187 y1, y0 = y0, y1 188 189 dx = x1 - x0 190 dy = y1 - y0 191 192 if dx == 0 and dy == 0: 193 self.point(x0, y0, char) 194 elif abs(dx) >= abs(dy): 195 for x in range(x0, x1 + 1): 196 if dx == 0: 197 y = y0 198 else: 199 y = y0 + int(round((x - x0) * dy / float((dx)))) 200 self.point(x, y, char) 201 elif y0 < y1: 202 for y in range(y0, y1 + 1): 203 if dy == 0: 204 x = x0 205 else: 206 x = x0 + int(round((y - y0) * dx / float((dy)))) 207 self.point(x, y, char) 208 else: 209 for y in range(y1, y0 + 1): 210 if dy == 0: 211 x = x0 212 else: 213 x = x1 + int(round((y - y1) * dx / float((dy)))) 214 self.point(x, y, char) 215 216 def text(self, x, y, text): 217 """Print a text on ASCII canvas. 218 219 Args: 220 x (int): x coordinate where the text should start. 221 y (int): y coordinate where the text should start. 222 text (str): string that should be printed. 223 """ 224 for i, char in enumerate(text): 225 self.point(x + i, y, char) 226 227 def box(self, x0, y0, width, height): 228 """Create a box on ASCII canvas. 229 230 Args: 231 x0 (int): x coordinate of the box corner. 232 y0 (int): y coordinate of the box corner. 233 width (int): box width. 234 height (int): box height. 235 """ 236 assert width > 1 237 assert height > 1 238 239 width -= 1 240 height -= 1 241 242 for x in range(x0, x0 + width): 243 self.point(x, y0, "-") 244 self.point(x, y0 + height, "-") 245 246 for y in range(y0, y0 + height): 247 self.point(x0, y, "|") 248 self.point(x0 + width, y, "|") 249 250 self.point(x0, y0, "+") 251 self.point(x0 + width, y0, "+") 252 self.point(x0, y0 + height, "+") 253 self.point(x0 + width, y0 + height, "+") 254 255 256def _build_sugiyama_layout(vertexes, edges): 257 # 258 # Just a reminder about naming conventions: 259 # +------------X 260 # | 261 # | 262 # | 263 # | 264 # Y 265 # 266 267 vertexes = {v: Vertex(" {} ".format(v)) for v in vertexes} 268 # NOTE: reverting edges to correctly orientate the graph 269 edges = [Edge(vertexes[e], vertexes[s]) for s, e in edges] 270 vertexes = vertexes.values() 271 graph = Graph(vertexes, edges) 272 273 for vertex in vertexes: 274 vertex.view = VertexViewer(vertex.data) 275 276 # NOTE: determine min box length to create the best layout 277 minw = min([v.view.w for v in vertexes]) 278 279 for edge in edges: 280 edge.view = EdgeViewer() 281 282 sug = SugiyamaLayout(graph.C[0]) 283 graph = graph.C[0] 284 roots = list(filter(lambda x: len(x.e_in()) == 0, graph.sV)) 285 286 sug.init_all(roots=roots, optimize=True) 287 288 sug.yspace = VertexViewer.HEIGHT 289 sug.xspace = minw 290 sug.route_edge = route_with_lines 291 292 sug.draw() 293 294 return sug 295 296 297def draw(vertexes, edges): 298 """Build a DAG and draw it in ASCII. 299 300 Args: 301 vertexes (list): list of graph vertexes. 302 edges (list): list of graph edges. 303 """ 304 # pylint: disable=too-many-locals 305 # NOTE: coordinates might me negative, so we need to shift 306 # everything to the positive plane before we actually draw it. 307 Xs = [] # pylint: disable=invalid-name 308 Ys = [] # pylint: disable=invalid-name 309 310 sug = _build_sugiyama_layout(vertexes, edges) 311 312 for vertex in sug.g.sV: 313 # NOTE: moving boxes w/2 to the left 314 Xs.append(vertex.view.xy[0] - vertex.view.w / 2.0) 315 Xs.append(vertex.view.xy[0] + vertex.view.w / 2.0) 316 Ys.append(vertex.view.xy[1]) 317 Ys.append(vertex.view.xy[1] + vertex.view.h) 318 319 for edge in sug.g.sE: 320 for x, y in edge.view._pts: # pylint: disable=protected-access 321 Xs.append(x) 322 Ys.append(y) 323 324 minx = min(Xs) 325 miny = min(Ys) 326 maxx = max(Xs) 327 maxy = max(Ys) 328 329 canvas_cols = int(math.ceil(math.ceil(maxx) - math.floor(minx))) + 1 330 canvas_lines = int(round(maxy - miny)) 331 332 canvas = AsciiCanvas(canvas_cols, canvas_lines) 333 334 # NOTE: first draw edges so that node boxes could overwrite them 335 for edge in sug.g.sE: 336 # pylint: disable=protected-access 337 assert len(edge.view._pts) > 1 338 for index in range(1, len(edge.view._pts)): 339 start = edge.view._pts[index - 1] 340 end = edge.view._pts[index] 341 342 start_x = int(round(start[0] - minx)) 343 start_y = int(round(start[1] - miny)) 344 end_x = int(round(end[0] - minx)) 345 end_y = int(round(end[1] - miny)) 346 347 assert start_x >= 0 348 assert start_y >= 0 349 assert end_x >= 0 350 assert end_y >= 0 351 352 canvas.line(start_x, start_y, end_x, end_y, "*") 353 354 for vertex in sug.g.sV: 355 # NOTE: moving boxes w/2 to the left 356 x = vertex.view.xy[0] - vertex.view.w / 2.0 357 y = vertex.view.xy[1] 358 359 canvas.box( 360 int(round(x - minx)), 361 int(round(y - miny)), 362 vertex.view.w, 363 vertex.view.h, 364 ) 365 366 canvas.text( 367 int(round(x - minx)) + 1, int(round(y - miny)) + 1, vertex.data 368 ) 369 370 canvas.draw() 371