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