Repository: GenericMappingTools/pygmt

Issue:
Wrapper for inset
**Description of the desired feature**

Implement [`inset`](https://docs.generic-mapping-tools.org/6.1/inset.html) which " is used to carve out a sub-region of the current plot canvas and restrict further plotting to that section of the canvas".

At the moment, users have to manually position inset maps using xshift and yshift (which was aliased in #624). Still, the results can be impressive as in this example (from https://github.com/mrsiegfried/Venturelli2020-GRL):

![Cool example of manually positioning map insets over Antarctica](https://user-images.githubusercontent.com/23487320/96937473-b5522e80-1524-11eb-847f-9f5dbcd6bc2a.png)

<!-- Please be as detailed as you can in your description. If possible, include an example of how you would like to use this feature (even better if it's a code example). -->

There's a gallery example over at GMT at https://docs.generic-mapping-tools.org/6.1/gallery/ex44.html on `inset`, and also a good GMT tutorial (in Chinese) at https://docs.gmt-china.org/latest/tutorial/inset/ we could translate over:

![GMT inset example](https://docs.gmt-china.org/latest/_images/inset-gmtplot-11.png)

How should we implement this though? This is actually quite tricky since we need to have the inset map *nested* inside an existing figure. The user experience side of things also needs to be considered, and it's tricky balancing the two.

Option 1 would be to put it in a `with` statement/context manager:

```python
with pygmt.inset(...):
    fig.basemap(...)
    fig.colorbar(...)
```

Option 2 might be to declare the start and end inset explicitly (similar, but not exactly the same as the current subplot wrapper at #427). :

```python
fig.begin_inset()
fig.basemap(...)
fig.colorbar(...)
fig.end_inset()
```

Thoughts? Any Option 3?

**Are you willing to help implement and maintain this feature?** Eventually, perhaps good to model the implementation after the subplot implementation at #427

<!-- Every feature we add is code that we will have to maintain and keep updated. This takes a lot of effort. If you are willing to be involved in the project and help maintain your feature, it will make it easier for us to accept it. -->


Discussion:
willschlitzer: The way I have currently wrapped this module is not passing a `@check_figures_equal` test, and it's giving me `AttributeError: 'tuple' object has no attribute 'savefig'`. I'm assuming this is the result of me adding **begin** and **end** to the argument string when calling the module, shown below: 

` lib.call_module("inset", "begin " + build_arg_string(kwargs))`
`lib.call_module("inset", "end " + build_arg_string(kwargs))`

I'm not sure how to get around this, as **begin** and **end** are a part of the GMT arguments, but `inset start` and `inset end` are not their standalone GMT modules that can be called with `lib.call_module()`. The inset functions appear to be working as expected when creating figures, but it looks like the addition to the kwargs string is not working with `generate_baseline_image`. Looking for any guidance on how to modify this so that it can pass the PyGMT standard tests.
seisman: To me, the option 1 (context manager) is more readable. It would be better to have "inset" and "subplot" using the same/similar syntax, but I haven't found time thinking about what the best design of "subplot" is.
seisman: If you want to learn about the context manager, you can read the implementation of the [`GMTTempFile`](https://github.com/GenericMappingTools/pygmt/blob/7181cf14e650dbee48988676c160b70a1cbd946e/pygmt/helpers/tempfile.py#L27) and https://book.pythontips.com/en/latest/context_managers.html.
weiji14: > To me, the option 1 (context manager) is more readable. It would be better to have "inset" and "subplot" using the same/similar syntax, but I haven't found time thinking about what the best design of "subplot" is.

Yeah, would be nice if `inset` and `subplot` had similar syntax, but `subplot` is a lot more complicated (see #427) to fit in a `with` statement. We'll definitely need to discuss this a bit more.

> If you want to learn about the context manager, you can read the implementation of the [`GMTTempFile`](https://github.com/GenericMappingTools/pygmt/blob/7181cf14e650dbee48988676c160b70a1cbd946e/pygmt/helpers/tempfile.py#L27) and https://book.pythontips.com/en/latest/context_managers.html.

`pygmt.config` also uses a context manager with an `__enter__` and `__exit__` function, and is a good guide if we decide to go the Option 1 route:

https://github.com/GenericMappingTools/pygmt/blob/2aa4aab1cc5cf925bbe01c9114d79ea9e1cb1a96/pygmt/modules.py#L196-L262

However, it requires the use of a Python class, and we (ideally) want `fig.inset` instead of `pygmt.inset`, so probably try the `@contextmanager` decorator method at https://book.pythontips.com/en/latest/context_managers.html#implementing-a-context-manager-as-a-generator.
willschlitzer: I'm happy to have a go at modifying this to use a context manager, but it's unfamiliar territory for me, so this will take me a little while to work on.
willschlitzer: @weiji14 I am not having any luck with moving to a context manager. I have added this to `base_plotting.py`:

    @fmt_docstring
    @contextlib.contextmanager
    @use_alias(
        D="location",
        V="verbose",
        F="border",
        M="margin",
    )
    @kwargs_to_strings(D="sequence", M="sequence")
    def inset(self, **kwargs):
        kwargs = self._preprocess(**kwargs)
        Session.call_module("inset", "begin " + build_arg_string(kwargs))
        finally:
            Session.call_module("inset", "end")


I haven't made it beyond `import pygmt`, as I get a syntax error for the `finally` statement. The context manager guide includes a `try` statement that I'm assuming is crucial to the context manager, but I'm not sure what I would have the function try to do. I get `TypeError: expected string or bytes-like object` when I put the `_preprocess` and `call_module` functions after `try`.
weiji14: > I haven't made it beyond `import pygmt`, as I get a syntax error for the `finally` statement. The context manager guide includes a `try` statement that I'm assuming is crucial to the context manager, but I'm not sure what I would have the function try to do.

Put `lib.call_module("inset", f"begin {build_arg_string(kwargs)}")` under `try:`.

> I get `TypeError: expected string or bytes-like object` when I put the `_preprocess` and `call_module` functions after `try`.

The TypeError is raised by `@fmt_docstring`. You need to add a docstring `""" """` under `def inset()`.

P.S. I see you're still submitting Pull Requests from your own fork. We need to teach you how to create branches directly on the GenericMappingTools/pygmt repo now that you're a maintainer!
willschlitzer: I would like to leave `inset` in `base_plotting.py` for now. I'm of the opinion that it will save us future headaches if every function has a stand-alone module, so it would eventually be moved. But I'm assuming v0.3 will be released before the functions are moved to their own modules, and I think `inset` will be used commonly enough to merit getting it in to the next release.
willschlitzer: @GenericMappingTools/python Could I get some feedback on this? I'm hoping to put it to rest, but I'm happy to make any changes as needed.
seisman: The PR looks good to me after addressing the above minor comments.

Patch:
diff --git a/doc/api/index.rst b/doc/api/index.rst
index 0598fd10..5ccf2a3f 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -37,6 +37,7 @@ Plotting data and laying out the map:
     Figure.shift_origin
     Figure.text
     Figure.meca
+    Figure.inset
 
 Color palette table generation:
 
diff --git a/doc/index.rst b/doc/index.rst
index 71fc08e9..399c7ad1 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -38,6 +38,7 @@
     tutorials/contour-map.rst
     tutorials/earth-relief.rst
     tutorials/3d-perspective-image.rst
+    tutorials/inset.rst
     tutorials/configuration.rst
 
 .. toctree::
diff --git a/examples/gallery/plot/inset.py b/examples/gallery/plot/inset.py
new file mode 100644
index 00000000..8f549b00
--- /dev/null
+++ b/examples/gallery/plot/inset.py
@@ -0,0 +1,25 @@
+"""
+Inset
+-----
+
+The :meth:`pygmt.Figure.inset` method adds an inset figure inside a larger
+figure. The function is called using a ``with`` statement, and its position,
+box, offset, and margin parameters are set. Within the ``with`` statement,
+PyGMT plotting functions can be called that add to the inset figure.
+"""
+import pygmt
+
+fig = pygmt.Figure()
+# Create the primary figure, setting the region to Madagascar, the land color to
+# "brown", the water to "lightblue", the shorelines width to "thin", and adding a frame
+fig.coast(region="MG+r2", land="brown", water="lightblue", shorelines="thin", frame="a")
+# Create an inset, setting the position to top left, the width to 3.5 centimeters, and
+# the x- and y-offsets to 0.2 centimeters. The margin is set to 0, and the border is "green".
+with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box="+pgreen"):
+    # Create a figure in the inset using coast. This example uses the azimuthal
+    # orthogonal projection centered at 47E, 20S. The land is set to "gray" and
+    # Madagascar is highlighted in "red".
+    fig.coast(
+        region="g", projection="G47/-20/3.5c", land="gray", water="white", dcw="MG+gred"
+    )
+fig.show()
diff --git a/examples/tutorials/inset.py b/examples/tutorials/inset.py
new file mode 100644
index 00000000..7d6b0b98
--- /dev/null
+++ b/examples/tutorials/inset.py
@@ -0,0 +1,110 @@
+"""
+Adding an inset to the figure
+=============================
+
+To plot an inset figure inside another larger figure, we can use the
+:meth:`pygmt.Figure.inset` method. After a large figure has been created,
+call ``inset`` using a ``with`` statement, and new plot elements will be
+added to the inset figure instead of the larger figure.
+"""
+# sphinx_gallery_thumbnail_number = 4
+
+import pygmt
+
+########################################################################################
+#
+# Prior to creating an inset figure, a larger figure must first be plotted. In the
+# example below, :meth:`pygmt.Figure.coast` is used to create a map of the US state of
+# Massachusetts.
+
+fig = pygmt.Figure()
+fig.coast(
+    region=[-74, -69.5, 41, 43],  # Set bounding box of the large figure
+    borders="2/thin",  # Plot state boundaries with thin lines
+    shorelines="thin",  # Plot coastline with thin lines
+    projection="M15c",  # Set Mercator projection and size of 15 centimeter
+    land="lightyellow",  # Color land areas light yellow
+    water="lightblue",  # Color water areas light blue
+    frame="a",  # Set frame with annotation and major tick spacing
+)
+fig.show()
+
+########################################################################################
+#
+# The :meth:`pygmt.Figure.inset` method uses a context manager, and is called using a
+# ``with`` statement. The ``position`` argument, including the inset width, is required
+# to plot the inset. Using the **j** argument, the location of the inset is
+# set to one of the 9 anchors (bottom-middle-top and left-center-right). In the
+# example below, ``BL`` sets the inset to the bottom left. The ``box`` argument can
+# set the fill and border of the inset. In the example below, ``+pblack`` sets the
+# border color to black and ``+gred`` sets the fill to red.
+
+fig = pygmt.Figure()
+fig.coast(
+    region=[-74, -69.5, 41, 43],
+    borders="2/thin",
+    shorelines="thin",
+    projection="M15c",
+    land="lightyellow",
+    water="lightblue",
+    frame="a",
+)
+with fig.inset(position="jBL+w3c", box="+pblack+glightred"):
+    # pass is used to exit the with statement as no plotting functions are called
+    pass
+fig.show()
+
+########################################################################################
+#
+# When using **j** to set the anchor of the inset, the default location is in
+# contact with the nearby axis or axes. The offset of the inset can be set with **+o**,
+# followed by the offsets along the x- and y-axis. If only one offset is
+# passed, it is applied to both axes. Each offset can have its own unit. In
+# the example below, the inset is shifted 0.5 centimeters on the x-axis and
+# 0.2 centimeters on the y-axis.
+
+fig = pygmt.Figure()
+fig.coast(
+    region=[-74, -69.5, 41, 43],
+    borders="2/thin",
+    shorelines="thin",
+    projection="M15c",
+    land="lightyellow",
+    water="lightblue",
+    frame="a",
+)
+with fig.inset(position="jBL+w3c+o0.5c/0.2c", box="+pblack+glightred"):
+    pass
+fig.show()
+
+########################################################################################
+#
+# Standard plotting functions can be called from within the ``inset`` context manager.
+# The example below uses :meth:`pygmt.Figure.coast` to plot a zoomed out map that
+# selectively paints the state of Massachusetts to shows its location relative to
+# other states.
+
+fig = pygmt.Figure()
+fig.coast(
+    region=[-74, -69.5, 41, 43],
+    borders="2/thin",
+    shorelines="thin",
+    projection="M15c",
+    land="lightyellow",
+    water="lightblue",
+    frame="a",
+)
+# This does not include an inset fill as it is covered by the inset figure
+with fig.inset(position="jBL+w3c+o0.5c/0.2c", box="+pblack"):
+    # Use a plotting function to create a figure inside the inset
+    fig.coast(
+        region=[-80, -65, 35, 50],
+        projection="M3c",
+        land="gray",
+        borders=[1, 2],
+        shorelines="1/thin",
+        water="white",
+        # Use dcw to selectively highlight an area
+        dcw="US.MA+gred",
+    )
+fig.show()
diff --git a/pygmt/base_plotting.py b/pygmt/base_plotting.py
index 3a28c989..b6992f88 100644
--- a/pygmt/base_plotting.py
+++ b/pygmt/base_plotting.py
@@ -1642,5 +1642,4 @@ class BasePlotting:
                 arg_str = " ".join([fname, build_arg_string(kwargs)])
                 lib.call_module("text", arg_str)
 
-    # GMT Supplementary modules
-    from pygmt.src import meca  # pylint: disable=import-outside-toplevel
+    from pygmt.src import inset, meca  # pylint: disable=import-outside-toplevel
diff --git a/pygmt/src/__init__.py b/pygmt/src/__init__.py
index cdbabf14..5950d92a 100644
--- a/pygmt/src/__init__.py
+++ b/pygmt/src/__init__.py
@@ -2,4 +2,5 @@
 Source code for PyGMT modules.
 """
 # pylint: disable=import-outside-toplevel
+from pygmt.src.inset import inset
 from pygmt.src.meca import meca
diff --git a/pygmt/src/inset.py b/pygmt/src/inset.py
new file mode 100644
index 00000000..95a5a040
--- /dev/null
+++ b/pygmt/src/inset.py
@@ -0,0 +1,132 @@
+"""
+inset - Create inset figures.
+"""
+import contextlib
+
+from pygmt.clib import Session
+from pygmt.helpers import build_arg_string, fmt_docstring, kwargs_to_strings, use_alias
+
+
+@fmt_docstring
+@contextlib.contextmanager
+@use_alias(D="position", F="box", M="margin", N="no_clip", V="verbose")
+@kwargs_to_strings(D="sequence", M="sequence")
+def inset(self, **kwargs):
+    r"""
+    Create an inset figure to be placed within a larger figure.
+
+    This function sets the position, frame, and margins for a smaller figure
+    inside of the larger figure. Plotting functions that are called within the
+    context manager are added to the inset figure.
+
+    Full option list at :gmt-docs:`inset.html`
+
+    {aliases}
+
+    Parameters
+    ----------
+    position : str or list
+        *xmin/xmax/ymin/ymax*\ [**+r**][**+u**\ *unit*]] \
+        | [**g**\|\ **j**\|\ **J**\|\ **n**\|\ **x**]\ *refpoint*\
+        **+w**\ *width*\ [/*height*][**+j**\ *justify*]
+        [**+o**\ *dx*\ [/*dy*]]
+
+        *This is the only required argument.*
+        Define the map inset rectangle on the map. Specify the rectangle
+        in one of three ways:
+
+        Append **g**\ *lon*/*lat* for map (user) coordinates,
+        **j**\ *code* or **J**\ *code* for setting the *refpoint* via a
+        2-char justification code \ that refers to the (invisible)
+        projected map bounding box, **n**\ *xn*/*yn* for normalized (0-1)
+        bounding box coordinates, or **x**\ *x*/*y* for plot
+        coordinates (inches, cm, points, append unit).
+        All but **x** requires both ``region`` and ``projection`` to be
+        specified. You can offset the reference point via
+        **+o**\ *dx*/*dy* in the direction implied by *code* or
+        **+j**\ *justify*.
+
+        Alternatively, Give *west/east/south/north* of geographic
+        rectangle bounded by parallels and meridians; append **+r** if the
+        coordinates instead are the lower left and upper right corners of
+        the desired rectangle. (Or, give *xmin/xmax/ymin/ymax* of bounding
+        rectangle in projected coordinates and optionally
+        append **+u**\ *unit* [Default coordinate unit is meter (e)].
+
+        Append **+w**\ *width*\ [/*height*] of bounding rectangle or box
+        in plot coordinates (inches, cm, etc.). By default, the anchor
+        point on the scale is assumed to be the bottom left corner (BL),
+        but this can be changed by appending **+j** followed by a 2-char
+        justification code *justify*.
+        **Note**: If **j** is used then *justify* defaults to the same
+        as *refpoint*, if **J** is used then *justify* defaults to the
+        mirror opposite of *refpoint*. Specify inset box attributes via
+        the ``box`` option [outline only].
+    box : str or bool
+        [**+c**\ *clearances*][**+g**\ *fill*][**+i**\ [[*gap*/]\
+        *pen*]][**+p**\ [*pen*]][**+r**\ [*radius*]][**+s**\
+        [[*dx*/*dy*/][*shade*]]]
+
+        If passed ``True``, this draws a rectangular box around the map
+        inset using the default pen; specify a different pen
+        with **+p**\ *pen*. Add **+g**\ *fill* to fill the logo box
+        [Default is no fill].
+        Append **+c**\ *clearance*  where *clearance* is either
+        *gap*, *xgap*\ /\ *ygap*, or *lgap*\ /\ *rgap*\ /\ *bgap*\ /\
+        *tgap* where these items are uniform, separate in x- and
+        y-direction, or individual side spacings between logo and border.
+        Append **+i** to draw a secondary, inner border as well. We use a
+        uniform *gap* between borders of 2\ **p** and the default pen
+        unless other values are specified. Append **+r** to draw rounded
+        rectangular borders instead, with a 6\ **p** corner radius. You
+        can override this radius by appending another value. Append
+        **+s** to draw an offset background shaded region. Here, *dx*/*dy*
+        indicates the shift relative to the foreground frame
+        [4\ **p**/-4\ **p**] and *shade* sets the fill style to use for
+        shading [Default is gray50].
+    margin : int or str or list
+        This is clearance that is added around the inside of the inset.
+        Plotting will take place within the inner region only. The margins
+        can be a single value, a pair of values separated (for setting
+        separate horizontal and vertical margins), or the full set of four
+        margins (for setting separate left, right, bottom, and top
+        margins). When passing multiple values, it can be either a list or
+        a string with the values separated by forward
+        slashes [Default is no margins].
+    no_clip : bool
+        Do NOT clip features extruding outside map inset boundaries [Default
+        will clip].
+    {V}
+
+    Examples
+    --------
+    >>> import pygmt
+    >>>
+    >>> # Create the larger figure
+    >>> fig = pygmt.Figure()
+    >>> fig.coast(region="MG+r2", water="lightblue", shorelines="thin")
+    >>> # Use a "with" statement to initialize the inset context manager
+    >>> # Setting the position to top left and a width of 3.5 centimeters
+    >>> with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box="+pgreen"):
+    ...     # Map elements under the "with" statement are plotted in the inset
+    ...     fig.coast(
+    ...         region="g",
+    ...         projection="G47/-20/3.5c",
+    ...         land="gray",
+    ...         water="white",
+    ...         dcw="MG+gred",
+    ...     )
+    ...
+    >>> # Map elements outside the "with" block are plotted in the main figure
+    >>> fig.logo(position="jBR+o0.2c+w3c")
+    >>> fig.show()
+    <IPython.core.display.Image object>
+    """
+    kwargs = self._preprocess(**kwargs)  # pylint: disable=protected-access
+    with Session() as lib:
+        try:
+            lib.call_module("inset", f"begin {build_arg_string(kwargs)}")
+            yield
+        finally:
+            v_arg = build_arg_string({"V": kwargs.get("V")})
+            lib.call_module("inset", f"end {v_arg}".strip())


Test patch:
diff --git a/pygmt/tests/test_inset.py b/pygmt/tests/test_inset.py
new file mode 100644
index 00000000..1d87b5a7
--- /dev/null
+++ b/pygmt/tests/test_inset.py
@@ -0,0 +1,42 @@
+"""
+Tests for the inset function.
+"""
+from pygmt import Figure
+from pygmt.helpers.testing import check_figures_equal
+
+
+@check_figures_equal()
+def test_inset_aliases():
+    """
+    Test the aliases for the inset function.
+    """
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.basemap(R="MG+r2", B="afg")
+    with fig_ref.inset(D="jTL+w3.5c+o0.2c", M=0, F="+pgreen"):
+        fig_ref.basemap(R="g", J="G47/-20/4c", B="afg")
+
+    fig_test.basemap(region="MG+r2", frame="afg")
+    with fig_test.inset(position="jTL+w3.5c+o0.2c", margin=0, box="+pgreen"):
+        fig_test.basemap(region="g", projection="G47/-20/4c", frame="afg")
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_inset_context_manager():
+    """
+    Test that the inset context manager works and, once closed, plotting
+    elements are added to the larger figure.
+    """
+    fig_ref, fig_test = Figure(), Figure()
+
+    fig_ref.basemap(region=[-74, -69.5, 41, 43], projection="M9c", frame=True)
+    fig_ref.basemap(rose="jTR+w3c")  # Pass rose argument with basemap before the inset
+    with fig_ref.inset(position="jBL+w3c+o0.2c", margin=0, box="+pblack"):
+        fig_ref.basemap(region=[-80, -65, 35, 50], projection="M3c", frame="afg")
+
+    fig_test.basemap(region=[-74, -69.5, 41, 43], projection="M9c", frame=True)
+    with fig_test.inset(position="jBL+w3c+o0.2c", margin=0, box="+pblack"):
+        fig_test.basemap(region=[-80, -65, 35, 50], projection="M3c", frame="afg")
+    fig_test.basemap(rose="jTR+w3c")  # Pass rose argument with basemap after the inset
+
+    return fig_ref, fig_test