first commit

This commit is contained in:
Chris Punches
2026-03-10 05:16:50 -04:00
commit 0d6b8a43f4
40 changed files with 30599 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.idea

34
CMakeLists.txt Normal file
View File

@@ -0,0 +1,34 @@
# GREX - A graphical frontend for creating and managing Rex project files.
# Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
project(grex LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK4 REQUIRED IMPORTED_TARGET gtk4)
file(GLOB_RECURSE GREX_SOURCES ${CMAKE_SOURCE_DIR}/src/*.cpp)
add_executable(grex ${GREX_SOURCES})
target_include_directories(grex PRIVATE
${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/src/vendor
)
target_link_libraries(grex PRIVATE PkgConfig::GTK4)

660
LICENSE.md Normal file
View File

@@ -0,0 +1,660 @@
# GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
## Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains
free software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing
under this license.
The precise terms and conditions for copying, distribution and
modification follow.
## TERMS AND CONDITIONS
### 0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public
License.
"Copyright" also means copyright-like laws that apply to other kinds
of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of
an exact copy. The resulting work is called a "modified version" of
the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user
through a computer network, with no transfer of a copy, is not
conveying.
An interactive user interface displays "Appropriate Legal Notices" to
the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
### 1. Source Code.
The "source code" for a work means the preferred form of the work for
making modifications to it. "Object code" means any non-source form of
a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can
regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same
work.
### 2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey,
without conditions so long as your license otherwise remains in force.
You may convey covered works to others for the sole purpose of having
them make modifications exclusively for you, or provide you with
facilities for running those works, provided that you comply with the
terms of this License in conveying all material for which you do not
control copyright. Those thus making or running the covered works for
you must do so exclusively on your behalf, under your direction and
control, on terms that prohibit them from making any copies of your
copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the
conditions stated below. Sublicensing is not allowed; section 10 makes
it unnecessary.
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such
circumvention is effected by exercising rights under this License with
respect to the covered work, and you disclaim any intention to limit
operation or modification of the work as a means of enforcing, against
the work's users, your or third parties' legal rights to forbid
circumvention of technological measures.
### 4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
### 5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these
conditions:
- a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
- b) The work must carry prominent notices stating that it is
released under this License and any conditions added under
section 7. This requirement modifies the requirement in section 4
to "keep intact all notices".
- c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
- d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
### 6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of
sections 4 and 5, provided that you also convey the machine-readable
Corresponding Source under the terms of this License, in one of these
ways:
- a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
- b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the Corresponding
Source from a network server at no charge.
- c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
- d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
- e) Convey the object code using peer-to-peer transmission,
provided you inform other peers where the object code and
Corresponding Source of the work are being offered to the general
public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal,
family, or household purposes, or (2) anything designed or sold for
incorporation into a dwelling. In determining whether a product is a
consumer product, doubtful cases shall be resolved in favor of
coverage. For a particular product received by a particular user,
"normally used" refers to a typical or common use of that class of
product, regardless of the status of the particular user or of the way
in which the particular user actually uses, or expects or is expected
to use, the product. A product is a consumer product regardless of
whether the product has substantial commercial, industrial or
non-consumer uses, unless such uses represent the only significant
mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to
install and execute modified versions of a covered work in that User
Product from a modified version of its Corresponding Source. The
information must suffice to ensure that the continued functioning of
the modified object code is in no case prevented or interfered with
solely because modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or
updates for a work that has been modified or installed by the
recipient, or for the User Product in which it has been modified or
installed. Access to a network may be denied when the modification
itself materially and adversely affects the operation of the network
or violates the rules and protocols for communication across the
network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
### 7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders
of that material) supplement the terms of this License with terms:
- a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
- b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
- c) Prohibiting misrepresentation of the origin of that material,
or requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
- d) Limiting the use for publicity purposes of names of licensors
or authors of the material; or
- e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
- f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions
of it) with contractual assumptions of liability to the recipient,
for any liability that these contractual assumptions directly
impose on those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions; the
above requirements apply either way.
### 8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your license
from a particular copyright holder is reinstated (a) provisionally,
unless and until the copyright holder explicitly and finally
terminates your license, and (b) permanently, if the copyright holder
fails to notify you of the violation by some reasonable means prior to
60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
### 9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run
a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
### 10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
### 11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned
or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within the
scope of its coverage, prohibits the exercise of, or is conditioned on
the non-exercise of one or more of the rights that are specifically
granted under this License. You may not convey a covered work if you
are a party to an arrangement with a third party that is in the
business of distributing software, under which you make payment to the
third party based on the extent of your activity of conveying the
work, and under which the third party grants, to any of the parties
who would receive the covered work from you, a discriminatory patent
license (a) in connection with copies of the covered work conveyed by
you (or copies made from those copies), or (b) primarily for and in
connection with specific products or compilations that contain the
covered work, unless you entered into that arrangement, or that patent
license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
### 12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under
this License and any other pertinent obligations, then as a
consequence you may not convey it at all. For example, if you agree to
terms that obligate you to collect a royalty for further conveying
from those to whom you convey the Program, the only way you could
satisfy both those terms and this License would be to refrain entirely
from conveying the Program.
### 13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your
version supports such interaction) an opportunity to receive the
Corresponding Source of your version by providing access to the
Corresponding Source from a network server at no charge, through some
standard or customary means of facilitating copying of software. This
Corresponding Source shall include the Corresponding Source for any
work covered by version 3 of the GNU General Public License that is
incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
### 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Affero General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever
published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions
of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
### 15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
CORRECTION.
### 16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
### 17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
## How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these
terms.
To do so, attach the following notices to the program. It is safest to
attach them to the start of each source file to most effectively state
the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper
mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for
the specific requirements.
You should also get your employer (if you work as a programmer) or
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. For more information on this, and how to apply and follow
the GNU AGPL, see <https://www.gnu.org/licenses/>.

327
README.md Normal file
View File

@@ -0,0 +1,327 @@
# GREX
**A graphical editor for Rex projects.**
AGPL-3.0 | Written by Chris Punches | [SILO GROUP](https://www.silogroup.org)
---
## What is GREX?
GREX is a desktop application for creating and managing
[Rex](https://git.silogroup.org/SILO-GROUP/rex) project files. Rex is SILO
GROUP's JSON-driven workflow execution system — it runs scripts and executables
in a predetermined order with logging, error handling, and self-healing
capabilities. Rex was originally built to drive the creation of
[Dark Horse Linux](https://www.darkhorselinux.org) but is designed for any
automation use case.
Rex projects are configured entirely through JSON files: configs, plans, unit
definitions, and shell definitions. GREX provides a visual interface for
managing all of these, replacing the need to hand-edit JSON. GREX edits Rex
project files — it does not execute them. Rex remains the runtime.
---
## Installation
### Prerequisites
- A C++17-capable compiler (GCC 8+ or Clang 7+)
- CMake 3.10 or later
- GTK4 development libraries
- pkg-config
The nlohmann/json library is vendored and does not require separate
installation.
### Building
```bash
cmake -B build
cmake --build build
```
The resulting binary is `build/grex`.
### Distribution Packages
Distribution-specific packages are not yet available. To install system-wide,
place the `grex` binary in a location on your `PATH` (e.g.,
`/usr/local/bin`).
---
## Getting Started
Launch GREX with no arguments to start with an empty session:
```bash
grex
```
Or open directly into a Rex project by passing a config file:
```bash
grex -c /path/to/rex.config
```
When you launch without a config, the first thing to do is open or create one
on the **Rex Config** tab. Once a config is loaded and its paths resolve
correctly, the **Plans**, **Units**, and **Shells** tabs become active.
On first launch, GREX creates its own settings file at
`~/.config/grex/grex.ini` with default values.
A status bar at the bottom of the window reports the results of load, save, and
error operations as you work.
---
## Working with Rex Configs
The **Rex Config** tab is where you load and manage your project's
configuration file.
### Opening and Creating Configs
- **Open Config** — Browse to an existing `rex.config` file.
- **Create Config** — Choose a location and filename to create a new, empty
config.
- **Close Config** — Unload the current config. The other tabs will be
disabled until a new config is loaded.
### Editing Configuration Values
Once a config is loaded, its key-value pairs are displayed as editable fields.
The standard keys are:
- `project_root` — The root directory of your Rex project.
- `units_path` — Path to the directory containing `.units` files (relative to
project root).
- `shells_path` — Path to the shells definition file (relative to project
root).
You can also define custom keys for your own use.
### Variables
Configuration values can contain variable references using `${VAR}` syntax.
For example:
```
project_root = ${HOME}/my-rex-project
```
GREX resolves these variables from your environment. If a variable is not set
in your environment, the **Variables** section will show it as unresolved and
let you provide an override value manually.
### Resolved Paths
The **Resolved Paths** section shows the fully-expanded values of
`project_root`, `units_dir`, and `shells_path` after variable substitution.
Check this section to confirm that your paths are pointing where you expect
before moving on to the other tabs.
### Saving
Click **Save** to write the config back to disk. The save button turns blue
when there are unsaved changes.
---
## Working with Plans
The **Plans** tab is where you create and manage execution plans — the ordered
sequences of tasks that Rex runs.
### Opening and Creating Plans
- **Open Plan** — Browse to an existing plan file.
- **Create Plan** — Choose a location and filename for a new, empty plan.
- **Close Plan** — Unload the current plan.
The current plan filename is shown in the header.
### Managing Tasks
The left panel lists the tasks in the current plan. Tasks are executed by Rex
in the order they appear.
- **Add** — Append a new empty task to the plan.
- **Delete** — Remove the selected task.
- **Move Up / Move Down** — Reorder the selected task.
### Editing a Task
Select a task in the list to view its properties in the right panel:
- **Name** — The name of the unit this task will execute (read-only display;
change it with the unit picker below).
- **Comment** — An optional note describing what this task does or why it
exists.
- **Change/Select Unit...** — Opens a picker dialog listing all units across
your loaded unit files. Select one to assign it to this task.
- **Edit Unit...** — Opens a modal dialog to edit the properties of the unit
assigned to this task.
- **Save Unit** — Writes changes to the unit's file on disk. Turns blue when
the unit has unsaved changes.
### Saving the Plan
Click **Save Plan** to write the plan file to disk. The button turns blue when
there are unsaved changes. If you switch tabs with unsaved changes, GREX will
prompt you to save or revert.
---
## Working with Units
The **Units** tab is where you manage unit definitions — the building blocks
that Rex executes.
### Browsing Unit Files
The left panel lists all `.units` files found in your project's units
directory. Select a file to see its units in the right panel.
### Managing Unit Files
- **New File** — Create a new `.units` file in your units directory.
- **Delete File** — Remove the selected file from the project (the file is not
deleted from disk).
### Managing Units Within a File
The right panel lists the units in the selected file, in the order they appear
in the file.
- **New Unit** — Add a new unit to the current file. You will be prompted for
a name. Unit names must be unique across all unit files in the project.
- **Delete Unit** — Remove the selected unit.
- **Edit Unit...** — Open a modal dialog to edit the selected unit's
properties.
- **Move Up / Move Down** — Reorder the selected unit within the file.
- **Rename** — Double-click a unit to rename it inline. Press Enter to confirm
or click elsewhere to cancel.
### Unit Properties
When you open the unit properties dialog (from either the Units or Plans tab),
you can configure:
- **Target** — The script or command that Rex will execute.
- **Shell Definition** — Which shell interpreter to use (selected from your
project's shell definitions).
- **Shell Command** — Whether the target is a shell command (interpreted by the
shell) or a direct path to an executable.
- **Force PTY** — Allocate a pseudo-terminal for execution.
- **Rectify / Rectifier** — Enable self-healing behavior. If the target fails
and rectification is enabled, Rex runs the rectifier script before
continuing. This is one of Rex's most powerful features — it allows
verification-and-fix patterns and preventive error recovery.
- **Active** — Whether Rex will include this unit when executing a plan. Inactive
units are skipped.
- **Required** — Whether failure of this unit should halt plan execution. If a
non-required unit fails (and rectification doesn't resolve it), Rex
continues to the next task.
- **User / Group** — Run the target under a specific user and group identity.
- **Working Directory** — Set a custom working directory for execution.
- **Environment** — Path to an environment file to source before execution.
You can also browse for file paths and open or create files in your configured
editor directly from the dialog.
### Saving
Click **Save Unit File** to write changes to disk. The button turns blue when
there are unsaved changes. Switching to a different unit file with unsaved
changes will prompt you to save or revert.
---
## Working with Shells
The **Shells** tab is where you manage shell definitions — the interpreters
that Rex uses to run unit targets.
### Managing Shell Definitions
The left panel lists all defined shells. Select one to edit its properties in
the right panel.
- **Add** — Create a new shell definition.
- **Delete** — Remove the selected shell definition.
### Shell Properties
Each shell definition has four fields:
- **Name** — An identifier for the shell (e.g., `bash`, `zsh`, `python3`).
This is the name referenced by units' shell definition setting.
- **Path** — The full filesystem path to the interpreter binary (e.g.,
`/bin/bash`).
- **Execution Arg** — The argument used to pass a command string for execution
(e.g., `-c`).
- **Source Cmd** — The command used to source environment files (e.g.,
`source` for bash, `.` for POSIX sh).
### Saving
Click **Save Shells** to write the shells file to disk.
---
## GREX Settings
Click the **Grex Config** button in the header bar to open GREX's own settings
dialog.
Currently, the only setting is:
- **File Editor** — The command GREX uses to open files for external editing.
The default is `xterm -e vim`. Change this to your preferred terminal editor
command (e.g., `alacritty -e nano`, `gnome-terminal -- vim`,
`xdg-open`).
GREX stores its settings at `~/.config/grex/grex.ini` (or under
`$XDG_CONFIG_HOME/grex/` if that variable is set). The format is simple
key-value:
```ini
file_editor=xterm -e vim
```
---
## Rex Concepts Quick Reference
| Concept | Meaning |
|---|---|
| **Project** | A Rex workspace anchored by a `rex.config` file. All paths are resolved relative to the project root. |
| **Config** | The `rex.config` file. Defines the project root, where to find units and shells, and any custom key-value settings. |
| **Plan** | An ordered list of tasks for Rex to execute sequentially. |
| **Task** | A single entry in a plan. Each task references a unit by name and optionally carries a comment. |
| **Unit** | A definition of something Rex can execute — a script, command, or binary — along with its execution context and error handling behavior. |
| **Shell** | An interpreter definition (name, binary path, execution argument, source command) that Rex uses to run shell-command units. |
| **Variable** | A `${VAR}` pattern in a config value, resolved from environment variables or manual overrides. Enables portable configs across machines. |
| **Rectification** | Rex's self-healing mechanism. When a unit's target fails and rectification is enabled, Rex runs the rectifier script. Used for verification-and-fix or preventive recovery patterns. |
---
## License
GREX is licensed under the
[GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.html)
(AGPL-3.0).
Copyright (C) 2025, SILO GROUP LLC. Written by Chris Punches.
---
## Links
- [Rex](https://git.silogroup.org/SILO-GROUP/rex) — The Rex execution engine
- [SILO GROUP](https://www.silogroup.org) — Distributed systems and consulting
- [Dark Horse Linux](https://www.darkhorselinux.org) — The Linux distribution Rex was originally built for

87
src/grex.cpp Normal file
View File

@@ -0,0 +1,87 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include <gtk/gtk.h>
#include <iostream>
#include <string>
#include "models/project.h"
#include "models/grex_config.h"
#include "views/main_window.h"
static grex::Project* g_project = nullptr;
static grex::GrexConfig* g_grex_config = nullptr;
static grex::MainWindow* g_main_window = nullptr;
static std::string g_config_path;
static int on_command_line(GApplication* app, GApplicationCommandLine* cmdline, gpointer) {
GVariantDict* options = g_application_command_line_get_options_dict(cmdline);
const gchar* config_path = nullptr;
g_variant_dict_lookup(options, "config", "&s", &config_path);
if (config_path)
g_config_path = config_path;
g_application_activate(app);
return 0;
}
static void on_activate(GtkApplication* app, gpointer) {
if (g_main_window) {
gtk_window_present(GTK_WINDOW(g_main_window->widget()));
return;
}
try {
g_grex_config = new grex::GrexConfig(grex::GrexConfig::load());
if (!g_config_path.empty()) {
g_project = new grex::Project(grex::Project::load(g_config_path));
} else {
g_project = new grex::Project();
}
g_main_window = new grex::MainWindow(app, *g_project, *g_grex_config);
gtk_window_present(GTK_WINDOW(g_main_window->widget()));
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
auto* dialog = gtk_alert_dialog_new("Failed to start:\n%s", e.what());
gtk_alert_dialog_show(dialog, nullptr);
g_object_unref(dialog);
}
}
int main(int argc, char* argv[]) {
auto* app = gtk_application_new("org.darkhorselinux.grex", G_APPLICATION_HANDLES_COMMAND_LINE);
GOptionEntry entries[] = {
{"config", 'c', 0, G_OPTION_ARG_STRING, nullptr, "Path to rex.config", "FILE"},
{nullptr}
};
g_application_add_main_option_entries(G_APPLICATION(app), entries);
g_signal_connect(app, "command-line", G_CALLBACK(on_command_line), nullptr);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
int status = g_application_run(G_APPLICATION(app), argc, argv);
g_object_unref(app);
delete g_main_window;
delete g_project;
delete g_grex_config;
return status;
}

View File

@@ -0,0 +1,74 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/grex_config.h"
#include <fstream>
#include <cstdlib>
namespace grex {
namespace fs = std::filesystem;
fs::path GrexConfig::config_path() {
const char* xdg = std::getenv("XDG_CONFIG_HOME");
fs::path dir;
if (xdg && xdg[0] != '\0') {
dir = fs::path(xdg) / "grex";
} else {
const char* home = std::getenv("HOME");
dir = fs::path(home ? home : "/tmp") / ".config" / "grex";
}
return dir / "grex.ini";
}
GrexConfig GrexConfig::load() {
GrexConfig cfg;
cfg.filepath_ = config_path();
if (!fs::exists(cfg.filepath_)) {
fs::create_directories(cfg.filepath_.parent_path());
cfg.file_editor = "xterm -e vim";
cfg.save();
return cfg;
}
std::ifstream in(cfg.filepath_);
std::string line;
while (std::getline(in, line)) {
if (line.empty() || line[0] == '#' || line[0] == ';')
continue;
auto eq = line.find('=');
if (eq == std::string::npos) continue;
auto key = line.substr(0, eq);
auto val = line.substr(eq + 1);
// trim whitespace
while (!key.empty() && key.back() == ' ') key.pop_back();
while (!val.empty() && val.front() == ' ') val.erase(val.begin());
if (key == "file_editor") cfg.file_editor = val;
}
return cfg;
}
void GrexConfig::save() const {
fs::create_directories(filepath_.parent_path());
std::ofstream out(filepath_);
out << "file_editor=" << file_editor << "\n";
}
}

37
src/models/grex_config.h Normal file
View File

@@ -0,0 +1,37 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <filesystem>
namespace grex {
class GrexConfig {
public:
static GrexConfig load();
void save() const;
std::string file_editor;
private:
std::filesystem::path filepath_;
static std::filesystem::path config_path();
};
}

55
src/models/plan.cpp Normal file
View File

@@ -0,0 +1,55 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/plan.h"
#include "util/json_helpers.h"
namespace grex {
void to_json(nlohmann::json& j, const Task& t) {
j = nlohmann::json{
{"name", t.name},
{"dependencies", t.dependencies}
};
if (t.comment.has_value())
j["comment"] = t.comment.value();
}
void from_json(const nlohmann::json& j, Task& t) {
j.at("name").get_to(t.name);
j.at("dependencies").get_to(t.dependencies);
if (j.contains("comment"))
t.comment = j.at("comment").get<std::string>();
}
Plan Plan::load(const std::filesystem::path& filepath) {
auto j = load_json_file(filepath);
Plan p;
p.name = filepath.stem().string();
p.filepath = filepath;
p.tasks = j.at("plan").get<std::vector<Task>>();
return p;
}
void Plan::save() const {
nlohmann::json j;
j["plan"] = tasks;
save_json_file(filepath, j);
}
}

46
src/models/plan.h Normal file
View File

@@ -0,0 +1,46 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <vector>
#include <optional>
#include <filesystem>
#include <nlohmann/json.hpp> // full include required: json used as member type
namespace grex {
struct Task {
std::string name;
nlohmann::json dependencies; // JSON array preserved as-is for round-trip fidelity
std::optional<std::string> comment;
};
void to_json(nlohmann::json& j, const Task& t);
void from_json(const nlohmann::json& j, Task& t);
struct Plan {
std::string name;
std::filesystem::path filepath;
std::vector<Task> tasks;
static Plan load(const std::filesystem::path& filepath);
void save() const;
};
}

248
src/models/project.cpp Normal file
View File

@@ -0,0 +1,248 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/project.h"
#include <set>
namespace grex {
namespace fs = std::filesystem;
void Project::report_status(const std::string& msg) {
if (status_cb)
status_cb(msg, status_cb_data);
}
fs::path Project::resolved_project_root() const {
auto val = config.get("project_root");
if (val.empty() || !resolver.can_resolve(val))
return {};
return resolver.resolve(val);
}
fs::path Project::resolved_units_dir() const {
auto root = resolved_project_root();
if (root.empty()) return {};
auto units = config.get("units_path");
if (units.empty()) return {};
return root / units;
}
fs::path Project::resolved_shells_path() const {
auto root = resolved_project_root();
if (root.empty()) return {};
auto sp = config.get("shells_path");
if (sp.empty()) return {};
return root / sp;
}
Unit* Project::find_unit(const std::string& unit_name) {
for (auto& uf : unit_files) {
auto* u = uf.find_unit(unit_name);
if (u) return u;
}
return nullptr;
}
UnitFile* Project::find_unit_file(const std::string& unit_name) {
for (auto& uf : unit_files) {
if (uf.find_unit(unit_name))
return &uf;
}
return nullptr;
}
bool Project::is_unit_name_taken(const std::string& name, const Unit* exclude) const {
for (auto& uf : unit_files) {
for (auto& u : uf.units) {
if (u.name == name && &u != exclude)
return true;
}
}
return false;
}
void Project::load_all_units() {
auto u_dir = resolved_units_dir();
if (u_dir.empty()) {
report_status("Error: units path not resolved");
return;
}
if (!fs::is_directory(u_dir)) {
report_status("Error: units path is not a directory: " + u_dir.string());
return;
}
std::vector<UnitFile> loaded;
int total_units = 0;
int file_count = 0;
int duplicates = 0;
std::set<std::string> seen_names;
for (auto& entry : fs::directory_iterator(u_dir)) {
if (entry.path().extension() == ".units") {
try {
auto uf = UnitFile::load(entry.path());
// Remove units with duplicate names
auto it = uf.units.begin();
while (it != uf.units.end()) {
if (seen_names.count(it->name)) {
report_status("Warning: duplicate unit '" + it->name +
"' in " + entry.path().filename().string() + " — skipped");
it = uf.units.erase(it);
duplicates++;
} else {
seen_names.insert(it->name);
++it;
}
}
total_units += (int)uf.units.size();
file_count++;
loaded.push_back(std::move(uf));
} catch (const std::exception& e) {
report_status("Error: failed to load " + entry.path().filename().string() + ": " + e.what());
}
}
}
unit_files = std::move(loaded);
auto msg = "Loaded " + std::to_string(total_units) + " units from " +
std::to_string(file_count) + " files at '" + u_dir.string() + "'";
if (duplicates > 0)
msg += " (" + std::to_string(duplicates) + " duplicates skipped)";
report_status(msg);
}
Project Project::load(const fs::path& cfg_path) {
Project proj;
proj.config_path = fs::canonical(cfg_path);
proj.config = RexConfig::load(proj.config_path);
// scan all config string values for variables, populate from environment
proj.resolver.scan_and_populate(proj.config.all_string_values());
// auto-load shells if path resolves
auto sp = proj.resolved_shells_path();
if (!sp.empty() && fs::exists(sp)) {
try {
proj.shells = ShellsFile::load(sp);
} catch (const std::exception& e) {
// no status_cb set yet at load time — caller handles
}
}
// auto-load all units if path resolves
proj.load_all_units();
return proj;
}
void Project::load_plan(const fs::path& plan_path) {
plans.clear();
try {
plans.push_back(Plan::load(plan_path));
report_status("Loaded plan: " + plan_path.filename().string());
} catch (const std::exception& e) {
report_status("Error: failed to load plan: " + std::string(e.what()));
throw;
}
}
void Project::load_shells(const fs::path& shells_path) {
shells = ShellsFile();
try {
shells = ShellsFile::load(shells_path);
report_status("Loaded shells: " + shells_path.filename().string());
} catch (const std::exception& e) {
report_status("Error: failed to load shells: " + std::string(e.what()));
throw;
}
}
void Project::reload_shells() {
auto sp = resolved_shells_path();
if (sp.empty()) {
report_status("Error: shells path not resolved");
return;
}
if (!fs::exists(sp)) {
report_status("Error: shells file not found: " + sp.string());
return;
}
try {
shells = ShellsFile::load(sp);
report_status("Loaded shells: " + sp.filename().string());
} catch (const std::exception& e) {
report_status("Error: failed to load shells: " + std::string(e.what()));
}
}
void Project::save_config() {
config.save(config_path);
report_status("Saved config: " + config_path.filename().string());
}
void Project::save_plans() {
for (auto& p : plans) {
p.save();
report_status("Saved plan: " + p.name);
}
}
void Project::save_shells() {
if (shells.filepath.empty()) {
report_status("Error: no shells loaded to save");
return;
}
shells.save();
report_status("Saved shells: " + shells.filepath.filename().string());
}
void Project::open_config(const fs::path& new_config_path) {
config_path = fs::canonical(new_config_path);
config = RexConfig::load(config_path);
resolver = VarResolver();
resolver.scan_and_populate(config.all_string_values());
// reload shells
shells = ShellsFile();
auto sp = resolved_shells_path();
if (!sp.empty() && fs::exists(sp)) {
try {
shells = ShellsFile::load(sp);
report_status("Loaded shells: " + sp.filename().string());
} catch (const std::exception& e) {
report_status(std::string("Error loading shells: ") + e.what());
}
}
// reload units
load_all_units();
// keep any loaded plans but note they may reference stale units now
report_status("Opened config: " + config_path.filename().string());
}
void Project::close_config() {
config = RexConfig();
config_path.clear();
resolver = VarResolver();
plans.clear();
unit_files.clear();
shells = ShellsFile();
report_status("Config closed");
}
}

68
src/models/project.h Normal file
View File

@@ -0,0 +1,68 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <filesystem>
#include <vector>
#include "models/rex_config.h"
#include "models/plan.h"
#include "models/unit.h"
#include "models/shell_def.h"
#include "util/var_resolver.h"
namespace grex {
class Project {
public:
static Project load(const std::filesystem::path& config_path);
RexConfig config;
std::filesystem::path config_path;
VarResolver resolver;
std::vector<Plan> plans;
std::vector<UnitFile> unit_files;
ShellsFile shells;
// status reporting
using StatusCallback = void(*)(const std::string& msg, void* data);
StatusCallback status_cb = nullptr;
void* status_cb_data = nullptr;
void report_status(const std::string& msg);
// resolved paths — empty if variables can't be resolved
std::filesystem::path resolved_project_root() const;
std::filesystem::path resolved_units_dir() const;
std::filesystem::path resolved_shells_path() const;
Unit* find_unit(const std::string& unit_name);
UnitFile* find_unit_file(const std::string& unit_name);
bool is_unit_name_taken(const std::string& name, const Unit* exclude = nullptr) const;
void load_all_units();
void load_plan(const std::filesystem::path& plan_path);
void load_shells(const std::filesystem::path& shells_path);
void reload_shells();
void save_config();
void save_plans();
void save_shells();
void open_config(const std::filesystem::path& new_config_path);
void close_config();
};
}

56
src/models/rex_config.cpp Normal file
View File

@@ -0,0 +1,56 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/rex_config.h"
#include "util/json_helpers.h"
namespace grex {
RexConfig RexConfig::load(const std::filesystem::path& filepath) {
auto j = load_json_file(filepath);
RexConfig c;
c.data_ = j.at("config");
return c;
}
void RexConfig::save(const std::filesystem::path& filepath) const {
nlohmann::json j;
j["config"] = data_;
save_json_file(filepath, j);
}
std::string RexConfig::get(const std::string& key) const {
if (data_.contains(key) && data_[key].is_string())
return data_[key].get<std::string>();
return {};
}
void RexConfig::set(const std::string& key, const std::string& value) {
data_[key] = value;
}
std::vector<std::string> RexConfig::all_string_values() const {
std::vector<std::string> vals;
for (auto& [key, val] : data_.items()) {
if (val.is_string())
vals.push_back(val.get<std::string>());
}
return vals;
}
}

47
src/models/rex_config.h Normal file
View File

@@ -0,0 +1,47 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <vector>
#include <filesystem>
#include <nlohmann/json.hpp> // full include required: json used as member type
namespace grex {
class RexConfig {
public:
static RexConfig load(const std::filesystem::path& filepath);
void save(const std::filesystem::path& filepath) const;
// access the raw json "config" object
nlohmann::json& data() { return data_; }
const nlohmann::json& data() const { return data_; }
// convenience: get a string value by key, empty string if missing
std::string get(const std::string& key) const;
void set(const std::string& key, const std::string& value);
// return all string values (for variable scanning)
std::vector<std::string> all_string_values() const;
private:
nlohmann::json data_;
};
}

54
src/models/shell_def.cpp Normal file
View File

@@ -0,0 +1,54 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/shell_def.h"
#include "util/json_helpers.h"
namespace grex {
void to_json(nlohmann::json& j, const ShellDef& s) {
j = nlohmann::json{
{"name", s.name},
{"path", s.path},
{"execution_arg", s.execution_arg},
{"source_cmd", s.source_cmd}
};
}
void from_json(const nlohmann::json& j, ShellDef& s) {
j.at("name").get_to(s.name);
j.at("path").get_to(s.path);
j.at("execution_arg").get_to(s.execution_arg);
j.at("source_cmd").get_to(s.source_cmd);
}
ShellsFile ShellsFile::load(const std::filesystem::path& filepath) {
auto j = load_json_file(filepath);
ShellsFile sf;
sf.filepath = filepath;
sf.shells = j.at("shells").get<std::vector<ShellDef>>();
return sf;
}
void ShellsFile::save() const {
nlohmann::json j;
j["shells"] = shells;
save_json_file(filepath, j);
}
}

45
src/models/shell_def.h Normal file
View File

@@ -0,0 +1,45 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <vector>
#include <filesystem>
#include <nlohmann/json.hpp> // full include required: json used in to_json/from_json signatures
namespace grex {
struct ShellDef {
std::string name;
std::string path;
std::string execution_arg;
std::string source_cmd;
};
void to_json(nlohmann::json& j, const ShellDef& s);
void from_json(const nlohmann::json& j, ShellDef& s);
struct ShellsFile {
std::filesystem::path filepath;
std::vector<ShellDef> shells;
static ShellsFile load(const std::filesystem::path& filepath);
void save() const;
};
}

86
src/models/unit.cpp Normal file
View File

@@ -0,0 +1,86 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "models/unit.h"
#include "util/json_helpers.h"
namespace grex {
void to_json(nlohmann::json& j, const Unit& u) {
j = nlohmann::json{
{"name", u.name},
{"target", u.target},
{"is_shell_command", u.is_shell_command},
{"shell_definition", u.shell_definition},
{"force_pty", u.force_pty},
{"set_working_directory", u.set_working_directory},
{"working_directory", u.working_directory},
{"rectify", u.rectify},
{"rectifier", u.rectifier},
{"active", u.active},
{"required", u.required},
{"set_user_context", u.set_user_context},
{"user", u.user},
{"group", u.group},
{"supply_environment", u.supply_environment},
{"environment", u.environment}
};
}
void from_json(const nlohmann::json& j, Unit& u) {
j.at("name").get_to(u.name);
j.at("target").get_to(u.target);
j.at("is_shell_command").get_to(u.is_shell_command);
j.at("shell_definition").get_to(u.shell_definition);
j.at("force_pty").get_to(u.force_pty);
j.at("set_working_directory").get_to(u.set_working_directory);
j.at("working_directory").get_to(u.working_directory);
j.at("rectify").get_to(u.rectify);
j.at("rectifier").get_to(u.rectifier);
j.at("active").get_to(u.active);
j.at("required").get_to(u.required);
j.at("set_user_context").get_to(u.set_user_context);
j.at("user").get_to(u.user);
j.at("group").get_to(u.group);
j.at("supply_environment").get_to(u.supply_environment);
j.at("environment").get_to(u.environment);
}
UnitFile UnitFile::load(const std::filesystem::path& filepath) {
auto j = load_json_file(filepath);
UnitFile uf;
uf.name = filepath.stem().string();
uf.filepath = filepath;
uf.units = j.at("units").get<std::vector<Unit>>();
return uf;
}
void UnitFile::save() const {
nlohmann::json j;
j["units"] = units;
save_json_file(filepath, j);
}
Unit* UnitFile::find_unit(const std::string& unit_name) {
for (auto& u : units)
if (u.name == unit_name)
return &u;
return nullptr;
}
}

59
src/models/unit.h Normal file
View File

@@ -0,0 +1,59 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <vector>
#include <filesystem>
#include <nlohmann/json.hpp> // full include required: json used as member type
namespace grex {
struct Unit {
std::string name;
std::string target;
bool is_shell_command = true;
std::string shell_definition = "bash";
bool force_pty = false;
bool set_working_directory = false;
std::string working_directory;
bool rectify = false;
std::string rectifier;
bool active = true;
bool required = true;
bool set_user_context = false;
std::string user;
std::string group;
bool supply_environment = false;
std::string environment;
};
void to_json(nlohmann::json& j, const Unit& u);
void from_json(const nlohmann::json& j, Unit& u);
struct UnitFile {
std::string name;
std::filesystem::path filepath;
std::vector<Unit> units;
static UnitFile load(const std::filesystem::path& filepath);
void save() const;
Unit* find_unit(const std::string& unit_name);
};
}

39
src/util/json_helpers.cpp Normal file
View File

@@ -0,0 +1,39 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/json_helpers.h"
#include <fstream>
#include <stdexcept>
namespace grex {
nlohmann::json load_json_file(const std::filesystem::path& path) {
std::ifstream f(path);
if (!f.is_open())
throw std::runtime_error("Cannot open file: " + path.string());
return nlohmann::json::parse(f);
}
void save_json_file(const std::filesystem::path& path, const nlohmann::json& j) {
std::ofstream f(path);
if (!f.is_open())
throw std::runtime_error("Cannot write file: " + path.string());
f << j.dump(1, '\t') << '\n';
}
}

28
src/util/json_helpers.h Normal file
View File

@@ -0,0 +1,28 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <filesystem>
#include <nlohmann/json.hpp>
namespace grex {
nlohmann::json load_json_file(const std::filesystem::path& path);
void save_json_file(const std::filesystem::path& path, const nlohmann::json& j);
}

125
src/util/unit_picker.cpp Normal file
View File

@@ -0,0 +1,125 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/unit_picker.h"
#include <cstring>
static int sort_listbox_alpha(GtkListBoxRow* a, GtkListBoxRow* b, gpointer) {
auto* la = GTK_LABEL(gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(a)));
auto* lb = GTK_LABEL(gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(b)));
return std::strcmp(gtk_label_get_text(la), gtk_label_get_text(lb));
}
namespace grex {
struct PickerData {
std::function<void(const std::string&)> on_select;
GtkWidget* listbox;
GtkWidget* window;
};
static std::string extract_unit_name(GtkListBoxRow* row) {
auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
auto name = std::string(gtk_label_get_text(GTK_LABEL(label)));
if (name.rfind("\u25C6 ", 0) == 0) name = name.substr(strlen("\u25C6 "));
return name;
}
void show_unit_picker(GtkWindow* parent, Project& project,
std::function<void(const std::string& unit_name)> on_select) {
auto* win = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(win), "Defined Units");
gtk_window_set_transient_for(GTK_WINDOW(win), parent);
gtk_window_set_modal(GTK_WINDOW(win), TRUE);
gtk_window_set_default_size(GTK_WINDOW(win), 350, 400);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_margin_start(box, 8);
gtk_widget_set_margin_end(box, 8);
gtk_widget_set_margin_top(box, 8);
gtk_widget_set_margin_bottom(box, 8);
auto* header = gtk_label_new("Select a defined unit...");
gtk_label_set_xalign(GTK_LABEL(header), 0.0f);
gtk_box_append(GTK_BOX(box), header);
auto* scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
auto* listbox = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(listbox), GTK_SELECTION_SINGLE);
gtk_list_box_set_sort_func(GTK_LIST_BOX(listbox), sort_listbox_alpha, nullptr, nullptr);
for (auto& uf : project.unit_files) {
for (auto& u : uf.units) {
auto* row = gtk_list_box_row_new();
auto utext = std::string("\u25C6 ") + u.name;
auto* label = gtk_label_new(utext.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 8);
gtk_widget_set_margin_end(label, 8);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
gtk_list_box_append(GTK_LIST_BOX(listbox), row);
}
}
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), listbox);
gtk_box_append(GTK_BOX(box), scroll);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_cancel = gtk_button_new_with_label("Cancel");
auto* btn_select = gtk_button_new_with_label("Select");
gtk_box_append(GTK_BOX(btn_row), btn_cancel);
gtk_box_append(GTK_BOX(btn_row), btn_select);
gtk_box_append(GTK_BOX(box), btn_row);
gtk_window_set_child(GTK_WINDOW(win), box);
auto* pd = new PickerData{std::move(on_select), listbox, win};
g_signal_connect(btn_cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* pd = static_cast<PickerData*>(d);
gtk_window_close(GTK_WINDOW(pd->window));
delete pd;
}), pd);
g_signal_connect(btn_select, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* pd = static_cast<PickerData*>(d);
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(pd->listbox));
if (!row) return;
pd->on_select(extract_unit_name(row));
gtk_window_close(GTK_WINDOW(pd->window));
delete pd;
}), pd);
g_signal_connect(listbox, "row-activated", G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer d) {
auto* pd = static_cast<PickerData*>(d);
pd->on_select(extract_unit_name(row));
gtk_window_close(GTK_WINDOW(pd->window));
delete pd;
}), pd);
gtk_window_present(GTK_WINDOW(win));
}
}

30
src/util/unit_picker.h Normal file
View File

@@ -0,0 +1,30 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <string>
#include <functional>
#include "models/project.h"
namespace grex {
void show_unit_picker(GtkWindow* parent, Project& project,
std::function<void(const std::string& unit_name)> on_select);
}

View File

@@ -0,0 +1,545 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/unit_properties_dialog.h"
#include <cstdlib>
#include <fstream>
namespace grex {
struct DialogState;
struct DlgSwitchBinding {
DialogState* state;
bool* target;
};
struct DlgEntryBinding {
DialogState* state;
std::string* target;
};
struct DialogState {
UnitDialogResult result = UnitDialogResult::Cancel;
GMainLoop* loop = nullptr;
GtkWidget* window = nullptr;
Unit working_copy;
Unit* original = nullptr;
Project* project = nullptr;
GrexConfig* grex_config = nullptr;
const std::vector<ShellDef>* shells = nullptr;
bool loading = false;
GCancellable* cancellable = nullptr;
// widgets
GtkWidget* entry_target = nullptr;
GtkWidget* box_target = nullptr;
GtkWidget* switch_shell_cmd = nullptr;
GtkWidget* dropdown_shell_def = nullptr;
GtkWidget* switch_force_pty = nullptr;
GtkWidget* switch_set_workdir = nullptr;
GtkWidget* entry_workdir = nullptr;
GtkWidget* box_workdir = nullptr;
GtkWidget* switch_rectify = nullptr;
GtkWidget* entry_rectifier = nullptr;
GtkWidget* switch_active = nullptr;
GtkWidget* switch_required = nullptr;
GtkWidget* switch_set_user_ctx = nullptr;
GtkWidget* entry_user = nullptr;
GtkWidget* entry_group = nullptr;
GtkWidget* switch_supply_env = nullptr;
GtkWidget* entry_environment = nullptr;
GtkWidget* box_environment = nullptr;
// labels for conditional visibility
GtkWidget* label_target = nullptr;
GtkWidget* label_shell_cmd = nullptr;
GtkWidget* label_shell_def = nullptr;
GtkWidget* label_force_pty = nullptr;
GtkWidget* label_set_workdir = nullptr;
GtkWidget* label_workdir = nullptr;
GtkWidget* label_rectify = nullptr;
GtkWidget* label_rectifier = nullptr;
GtkWidget* label_active = nullptr;
GtkWidget* label_required = nullptr;
GtkWidget* label_set_user_ctx = nullptr;
GtkWidget* label_user = nullptr;
GtkWidget* label_group = nullptr;
GtkWidget* label_supply_env = nullptr;
GtkWidget* label_environment = nullptr;
std::vector<DlgEntryBinding*> entry_bindings;
std::vector<DlgSwitchBinding*> switch_bindings;
std::vector<void*> helper_data;
};
static void update_sensitivity(DialogState* s);
static void dlg_switch_toggled_cb(GObject* sw, GParamSpec*, gpointer data) {
auto* b = static_cast<DlgSwitchBinding*>(data);
if (b->state) {
*b->target = gtk_switch_get_active(GTK_SWITCH(sw));
if (!b->state->loading)
update_sensitivity(b->state);
}
}
static int shell_index(const std::vector<ShellDef>& shells, const std::string& name) {
for (size_t i = 0; i < shells.size(); i++)
if (shells[i].name == name) return static_cast<int>(i);
return 0;
}
static GtkWidget* make_switch_row(GtkWidget* grid, int row, const char* label_text, GtkWidget** label_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* sw = gtk_switch_new();
gtk_widget_set_halign(sw, GTK_ALIGN_START);
gtk_grid_attach(GTK_GRID(grid), sw, 1, row, 1, 1);
return sw;
}
static GtkWidget* make_entry_row(GtkWidget* grid, int row, const char* label_text, GtkWidget** label_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_grid_attach(GTK_GRID(grid), entry, 1, row, 1, 1);
return entry;
}
static GtkWidget* make_browse_row(DialogState* s, GtkWidget* grid, int row, const char* label_text,
GtkWidget** label_out, GtkWidget** box_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_hexpand(hbox, TRUE);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_box_append(GTK_BOX(hbox), entry);
auto* btn = gtk_button_new_with_label("Select");
gtk_box_append(GTK_BOX(hbox), btn);
struct BrowseBtnData {
DialogState* state;
GtkWidget* entry;
};
auto* bbd = new BrowseBtnData{s, entry};
s->helper_data.push_back(bbd);
g_signal_connect(btn, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* bbd = static_cast<BrowseBtnData*>(d);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(bbd->entry, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Select File");
gtk_file_dialog_set_accept_label(dialog, "Select");
auto root = bbd->state->project->resolved_project_root();
if (!root.empty()) {
auto* initial = g_file_new_for_path(root.c_str());
gtk_file_dialog_set_initial_folder(dialog, initial);
g_object_unref(initial);
}
struct BD { GtkWidget* entry; };
auto* bd = new BD{bbd->entry};
gtk_file_dialog_open(dialog, window, bbd->state->cancellable,
+[](GObject* source, GAsyncResult* res, gpointer data) {
auto* bd = static_cast<BD*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), res, &error);
if (file) {
auto* path = g_file_get_path(file);
gtk_editable_set_text(GTK_EDITABLE(bd->entry), path);
g_free(path);
g_object_unref(file);
} else if (error) {
g_error_free(error);
}
delete bd;
}, bd);
}), bbd);
if (box_out) *box_out = hbox;
gtk_grid_attach(GTK_GRID(grid), hbox, 1, row, 1, 1);
return entry;
}
static GtkWidget* make_file_row(DialogState* s, GtkWidget* grid, int row, const char* label_text,
GtkWidget** label_out, GtkWidget** box_out) {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1);
if (label_out) *label_out = label;
auto* hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_hexpand(hbox, TRUE);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_box_append(GTK_BOX(hbox), entry);
auto* btn_browse = gtk_button_new_with_label("Select");
auto* btn_open = gtk_button_new_with_label("Open");
auto* btn_new = gtk_button_new_with_label("Create");
gtk_box_append(GTK_BOX(hbox), btn_browse);
gtk_box_append(GTK_BOX(hbox), btn_open);
gtk_box_append(GTK_BOX(hbox), btn_new);
struct FileBtnData {
DialogState* state;
GtkWidget* entry;
};
auto* fbd = new FileBtnData{s, entry};
s->helper_data.push_back(fbd);
// Browse
g_signal_connect(btn_browse, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* fbd = static_cast<FileBtnData*>(d);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(fbd->entry, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Select File");
gtk_file_dialog_set_accept_label(dialog, "Select");
auto root = fbd->state->project->resolved_project_root();
if (!root.empty()) {
auto* initial = g_file_new_for_path(root.c_str());
gtk_file_dialog_set_initial_folder(dialog, initial);
g_object_unref(initial);
}
struct CB { GtkWidget* entry; };
auto* cb = new CB{fbd->entry};
gtk_file_dialog_open(dialog, window, fbd->state->cancellable,
+[](GObject* source, GAsyncResult* res, gpointer data) {
auto* cb = static_cast<CB*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), res, &error);
if (file) {
auto* path = g_file_get_path(file);
gtk_editable_set_text(GTK_EDITABLE(cb->entry), path);
g_free(path);
g_object_unref(file);
} else if (error) {
g_error_free(error);
}
delete cb;
}, cb);
}), fbd);
// Open
g_signal_connect(btn_open, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* fbd = static_cast<FileBtnData*>(d);
auto raw = std::string(gtk_editable_get_text(GTK_EDITABLE(fbd->entry)));
if (raw.empty()) {
fbd->state->project->report_status("Error: no file path set");
return;
}
namespace fs = std::filesystem;
fs::path p(raw);
if (p.is_relative()) {
auto root = fbd->state->project->resolved_project_root();
if (!root.empty())
p = root / p;
}
std::error_code ec;
auto canonical = fs::canonical(p, ec);
auto full = ec ? p : canonical;
if (!fs::exists(full)) {
fbd->state->project->report_status("Error: file not found: " + full.string());
return;
}
auto cmd = fbd->state->grex_config->file_editor + " \"" + full.string() + "\" &";
std::system(cmd.c_str());
}), fbd);
// Create
auto on_new_clicked = +[](GtkButton*, gpointer d) {
auto* fbd = static_cast<FileBtnData*>(d);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(fbd->entry, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Create New File");
auto root = fbd->state->project->resolved_project_root();
if (!root.empty()) {
auto* initial = g_file_new_for_path(root.c_str());
gtk_file_dialog_set_initial_folder(dialog, initial);
g_object_unref(initial);
}
struct NewCB {
DialogState* state;
GtkWidget* entry;
};
auto* ncb = new NewCB{fbd->state, fbd->entry};
auto on_save_response = +[](GObject* source, GAsyncResult* res, gpointer data) {
auto* ncb = static_cast<NewCB*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), res, &error);
if (file) {
auto* path = g_file_get_path(file);
std::ofstream ofs(path);
ofs.close();
gtk_editable_set_text(GTK_EDITABLE(ncb->entry), path);
auto cmd = ncb->state->grex_config->file_editor + " \"" + std::string(path) + "\" &";
std::system(cmd.c_str());
g_free(path);
g_object_unref(file);
} else if (error) {
g_error_free(error);
}
delete ncb;
};
gtk_file_dialog_save(dialog, window, fbd->state->cancellable, on_save_response, ncb);
};
g_signal_connect(btn_new, "clicked", G_CALLBACK(on_new_clicked), fbd);
if (box_out) *box_out = hbox;
gtk_grid_attach(GTK_GRID(grid), hbox, 1, row, 1, 1);
return entry;
}
static void update_sensitivity(DialogState* s) {
auto show = [](bool visible, std::initializer_list<GtkWidget*> widgets) {
for (auto* w : widgets)
gtk_widget_set_visible(w, visible);
};
bool active = s->working_copy.active;
show(active, {
s->label_target, s->box_target,
s->label_shell_cmd, s->switch_shell_cmd,
s->label_set_workdir, s->switch_set_workdir,
s->label_rectify, s->switch_rectify,
s->label_required, s->switch_required,
s->label_set_user_ctx, s->switch_set_user_ctx,
s->label_supply_env, s->switch_supply_env,
});
show(active && s->working_copy.is_shell_command, {s->label_shell_def, s->dropdown_shell_def, s->label_force_pty, s->switch_force_pty});
show(active && s->working_copy.set_working_directory, {s->label_workdir, s->box_workdir});
show(active && s->working_copy.rectify, {s->label_rectifier, s->entry_rectifier});
show(active && s->working_copy.set_user_context, {s->label_user, s->entry_user, s->label_group, s->entry_group});
show(active && s->working_copy.supply_environment, {s->label_environment, s->box_environment});
}
static void populate_and_connect(DialogState* s) {
auto& u = s->working_copy;
// Rebuild shell dropdown model
auto* string_list = gtk_string_list_new(nullptr);
for (auto& sh : *s->shells)
gtk_string_list_append(string_list, sh.name.c_str());
gtk_drop_down_set_model(GTK_DROP_DOWN(s->dropdown_shell_def), G_LIST_MODEL(string_list));
g_object_unref(string_list);
s->loading = true;
gtk_editable_set_text(GTK_EDITABLE(s->entry_target), u.target.c_str());
gtk_switch_set_active(GTK_SWITCH(s->switch_shell_cmd), u.is_shell_command);
gtk_drop_down_set_selected(GTK_DROP_DOWN(s->dropdown_shell_def), shell_index(*s->shells, u.shell_definition));
gtk_switch_set_active(GTK_SWITCH(s->switch_force_pty), u.force_pty);
gtk_switch_set_active(GTK_SWITCH(s->switch_set_workdir), u.set_working_directory);
gtk_editable_set_text(GTK_EDITABLE(s->entry_workdir), u.working_directory.c_str());
gtk_switch_set_active(GTK_SWITCH(s->switch_rectify), u.rectify);
gtk_editable_set_text(GTK_EDITABLE(s->entry_rectifier), u.rectifier.c_str());
gtk_switch_set_active(GTK_SWITCH(s->switch_active), u.active);
gtk_switch_set_active(GTK_SWITCH(s->switch_required), u.required);
gtk_switch_set_active(GTK_SWITCH(s->switch_set_user_ctx), u.set_user_context);
gtk_editable_set_text(GTK_EDITABLE(s->entry_user), u.user.c_str());
gtk_editable_set_text(GTK_EDITABLE(s->entry_group), u.group.c_str());
gtk_switch_set_active(GTK_SWITCH(s->switch_supply_env), u.supply_environment);
gtk_editable_set_text(GTK_EDITABLE(s->entry_environment), u.environment.c_str());
update_sensitivity(s);
s->loading = false;
// Entry bindings
auto bind_entry = [s](GtkWidget* entry, std::string* target) {
auto* eb = new DlgEntryBinding{s, target};
s->entry_bindings.push_back(eb);
g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* eb = static_cast<DlgEntryBinding*>(d);
*eb->target = gtk_editable_get_text(e);
}), eb);
};
bind_entry(s->entry_target, &u.target);
bind_entry(s->entry_workdir, &u.working_directory);
bind_entry(s->entry_rectifier, &u.rectifier);
bind_entry(s->entry_user, &u.user);
bind_entry(s->entry_group, &u.group);
bind_entry(s->entry_environment, &u.environment);
// Switch bindings
auto bind_switch = [s](GtkWidget* sw, bool* target) {
auto* sb = new DlgSwitchBinding{s, target};
s->switch_bindings.push_back(sb);
g_signal_connect(sw, "notify::active", G_CALLBACK(dlg_switch_toggled_cb), sb);
};
bind_switch(s->switch_shell_cmd, &u.is_shell_command);
bind_switch(s->switch_force_pty, &u.force_pty);
bind_switch(s->switch_set_workdir, &u.set_working_directory);
bind_switch(s->switch_rectify, &u.rectify);
bind_switch(s->switch_active, &u.active);
bind_switch(s->switch_required, &u.required);
bind_switch(s->switch_set_user_ctx, &u.set_user_context);
bind_switch(s->switch_supply_env, &u.supply_environment);
// Shell dropdown
g_signal_connect(s->dropdown_shell_def, "notify::selected", G_CALLBACK(+[](GObject* obj, GParamSpec*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
if (s->loading) return;
auto idx = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj));
if (idx < s->shells->size())
s->working_copy.shell_definition = (*s->shells)[idx].name;
}), s);
}
UnitDialogResult show_unit_properties_dialog(GtkWindow* parent,
Unit* unit, Project& project, GrexConfig& grex_config,
const std::vector<ShellDef>& shells)
{
auto* win = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(win), ("Unit Properties: " + unit->name).c_str());
gtk_window_set_transient_for(GTK_WINDOW(win), parent);
gtk_window_set_modal(GTK_WINDOW(win), TRUE);
gtk_window_set_default_size(GTK_WINDOW(win), 600, 550);
auto* outer_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_start(outer_box, 16);
gtk_widget_set_margin_end(outer_box, 16);
gtk_widget_set_margin_top(outer_box, 16);
gtk_widget_set_margin_bottom(outer_box, 16);
// Unit name header
auto* name_header = gtk_label_new(nullptr);
auto header_markup = std::string("<big><b>") + unit->name + "</b></big>";
gtk_label_set_markup(GTK_LABEL(name_header), header_markup.c_str());
gtk_widget_set_halign(name_header, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(outer_box), name_header);
// Scrolled content area
auto* scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
auto* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
auto* loop = g_main_loop_new(nullptr, FALSE);
auto* cancellable = g_cancellable_new();
DialogState state{};
state.loop = loop;
state.window = win;
state.working_copy = *unit;
state.original = unit;
state.project = &project;
state.grex_config = &grex_config;
state.shells = &shells;
state.cancellable = cancellable;
int r = 0;
state.entry_target = make_file_row(&state, grid, r++, "Target", &state.label_target, &state.box_target);
state.switch_shell_cmd = make_switch_row(grid, r++, "Shell Command", &state.label_shell_cmd);
// Shell definition dropdown
state.label_shell_def = gtk_label_new("Shell Definition");
gtk_label_set_xalign(GTK_LABEL(state.label_shell_def), 1.0f);
gtk_grid_attach(GTK_GRID(grid), state.label_shell_def, 0, r, 1, 1);
auto* string_list = gtk_string_list_new(nullptr);
state.dropdown_shell_def = gtk_drop_down_new(G_LIST_MODEL(string_list), nullptr);
gtk_widget_set_hexpand(state.dropdown_shell_def, TRUE);
gtk_grid_attach(GTK_GRID(grid), state.dropdown_shell_def, 1, r++, 1, 1);
state.switch_force_pty = make_switch_row(grid, r++, "Force PTY", &state.label_force_pty);
state.switch_set_workdir = make_switch_row(grid, r++, "Set Working Dir", &state.label_set_workdir);
state.entry_workdir = make_browse_row(&state, grid, r++, "Working Directory", &state.label_workdir, &state.box_workdir);
state.switch_rectify = make_switch_row(grid, r++, "Rectify", &state.label_rectify);
state.entry_rectifier = make_entry_row(grid, r++, "Rectifier", &state.label_rectifier);
state.switch_active = make_switch_row(grid, r++, "Active", &state.label_active);
state.switch_required = make_switch_row(grid, r++, "Required", &state.label_required);
state.switch_set_user_ctx = make_switch_row(grid, r++, "Set User Context", &state.label_set_user_ctx);
state.entry_user = make_entry_row(grid, r++, "User", &state.label_user);
state.entry_group = make_entry_row(grid, r++, "Group", &state.label_group);
state.switch_supply_env = make_switch_row(grid, r++, "Supply Environment", &state.label_supply_env);
state.entry_environment = make_file_row(&state, grid, r++, "Environment", &state.label_environment, &state.box_environment);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), grid);
gtk_box_append(GTK_BOX(outer_box), scroll);
// Button row
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_cancel = gtk_button_new_with_label("Cancel");
auto* btn_save = gtk_button_new_with_label("Save");
gtk_widget_add_css_class(btn_save, "suggested-action");
gtk_box_append(GTK_BOX(btn_row), btn_cancel);
gtk_box_append(GTK_BOX(btn_row), btn_save);
gtk_box_append(GTK_BOX(outer_box), btn_row);
gtk_window_set_child(GTK_WINDOW(win), outer_box);
gtk_window_set_default_widget(GTK_WINDOW(win), btn_save);
// Populate widgets and connect signals
populate_and_connect(&state);
// Button signals
g_signal_connect(btn_cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
s->result = UnitDialogResult::Cancel;
gtk_window_close(GTK_WINDOW(s->window));
}), &state);
g_signal_connect(btn_save, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
*s->original = s->working_copy;
s->result = UnitDialogResult::Save;
gtk_window_close(GTK_WINDOW(s->window));
}), &state);
g_signal_connect(win, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer d) {
auto* s = static_cast<DialogState*>(d);
g_main_loop_quit(s->loop);
}), &state);
gtk_window_present(GTK_WINDOW(win));
g_main_loop_run(loop);
g_main_loop_unref(loop);
// Cleanup
g_cancellable_cancel(cancellable);
g_object_unref(cancellable);
for (auto* eb : state.entry_bindings) delete eb;
for (auto* sb : state.switch_bindings) delete sb;
for (auto* p : state.helper_data) ::operator delete(p);
return state.result;
}
}

View File

@@ -0,0 +1,35 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <vector>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
enum class UnitDialogResult { Save, Cancel };
// Blocking modal dialog for editing unit properties.
// Edits a working copy; writes back to the original Unit on Save.
UnitDialogResult show_unit_properties_dialog(GtkWindow* parent,
Unit* unit, Project& project, GrexConfig& grex_config,
const std::vector<ShellDef>& shells);
}

View File

@@ -0,0 +1,89 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/unsaved_dialog.h"
namespace grex {
struct DialogState {
UnsavedResult result;
GMainLoop* loop;
GtkWidget* window;
};
static void on_revert_clicked(GtkButton*, gpointer d) {
auto* state = static_cast<DialogState*>(d);
state->result = UnsavedResult::Revert;
gtk_window_close(GTK_WINDOW(state->window));
}
static void on_save_clicked(GtkButton*, gpointer d) {
auto* state = static_cast<DialogState*>(d);
state->result = UnsavedResult::Save;
gtk_window_close(GTK_WINDOW(state->window));
}
static void on_dialog_closed(GtkWidget*, gpointer d) {
auto* state = static_cast<DialogState*>(d);
g_main_loop_quit(state->loop);
}
UnsavedResult show_unsaved_dialog(GtkWindow* parent) {
auto* win = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(win), "Unsaved Changes");
gtk_window_set_transient_for(GTK_WINDOW(win), parent);
gtk_window_set_modal(GTK_WINDOW(win), TRUE);
gtk_window_set_default_size(GTK_WINDOW(win), 350, -1);
gtk_window_set_resizable(GTK_WINDOW(win), FALSE);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_start(box, 16);
gtk_widget_set_margin_end(box, 16);
gtk_widget_set_margin_top(box, 16);
gtk_widget_set_margin_bottom(box, 16);
auto* label = gtk_label_new("You have unsaved changes.");
gtk_box_append(GTK_BOX(box), label);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_revert = gtk_button_new_with_label("Revert");
auto* btn_save = gtk_button_new_with_label("Save");
gtk_widget_add_css_class(btn_save, "suggested-action");
gtk_box_append(GTK_BOX(btn_row), btn_revert);
gtk_box_append(GTK_BOX(btn_row), btn_save);
gtk_box_append(GTK_BOX(box), btn_row);
gtk_window_set_child(GTK_WINDOW(win), box);
gtk_window_set_default_widget(GTK_WINDOW(win), btn_save);
auto* loop = g_main_loop_new(nullptr, FALSE);
DialogState state{UnsavedResult::Save, loop, win};
g_signal_connect(btn_revert, "clicked", G_CALLBACK(on_revert_clicked), &state);
g_signal_connect(btn_save, "clicked", G_CALLBACK(on_save_clicked), &state);
g_signal_connect(win, "destroy", G_CALLBACK(on_dialog_closed), &state);
gtk_window_present(GTK_WINDOW(win));
g_main_loop_run(loop);
g_main_loop_unref(loop);
return state.result;
}
}

30
src/util/unsaved_dialog.h Normal file
View File

@@ -0,0 +1,30 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
namespace grex {
enum class UnsavedResult { Save, Revert };
// Shows a blocking modal "Unsaved Changes" dialog with Revert and Save buttons.
// Returns the user's choice. Uses a nested GLib main loop to block.
UnsavedResult show_unsaved_dialog(GtkWindow* parent);
}

107
src/util/var_resolver.cpp Normal file
View File

@@ -0,0 +1,107 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "util/var_resolver.h"
#include <cstdlib>
#include <regex>
namespace grex {
std::set<std::string> VarResolver::find_variables(const std::string& input) {
std::set<std::string> vars;
// match ${var_name} patterns
std::regex re(R"(\$\{([a-zA-Z_][a-zA-Z0-9_]*)\})");
auto begin = std::sregex_iterator(input.begin(), input.end(), re);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it)
vars.insert((*it)[1].str());
return vars;
}
void VarResolver::set(const std::string& name, const std::string& value) {
overrides_[name] = value;
}
std::string VarResolver::resolve(const std::string& input) const {
std::string result = input;
std::regex re(R"(\$\{([a-zA-Z_][a-zA-Z0-9_]*)\})");
std::string output;
auto begin = std::sregex_iterator(result.begin(), result.end(), re);
auto end = std::sregex_iterator();
size_t last_pos = 0;
for (auto it = begin; it != end; ++it) {
output.append(result, last_pos, it->position() - last_pos);
auto var_name = (*it)[1].str();
// check overrides first
auto ov = overrides_.find(var_name);
if (ov != overrides_.end() && !ov->second.empty()) {
output.append(ov->second);
} else {
// check process environment
const char* env_val = std::getenv(var_name.c_str());
if (env_val && env_val[0] != '\0') {
output.append(env_val);
} else {
// leave unresolved
output.append(it->str());
}
}
last_pos = it->position() + it->length();
}
output.append(result, last_pos);
return output;
}
bool VarResolver::can_resolve(const std::string& input) const {
auto vars = find_variables(input);
for (auto& v : vars) {
auto ov = overrides_.find(v);
if (ov != overrides_.end() && !ov->second.empty())
continue;
const char* env = std::getenv(v.c_str());
if (env && env[0] != '\0')
continue;
return false;
}
return true;
}
void VarResolver::scan_and_populate(const std::vector<std::string>& inputs) {
for (auto& input : inputs) {
auto vars = find_variables(input);
for (auto& v : vars) {
if (overrides_.find(v) != overrides_.end())
continue;
const char* env_val = std::getenv(v.c_str());
overrides_[v] = env_val ? env_val : "";
}
}
}
std::vector<std::string> VarResolver::unresolved() const {
std::vector<std::string> result;
for (auto& [name, value] : overrides_) {
if (value.empty() && !std::getenv(name.c_str()))
result.push_back(name);
}
return result;
}
}

57
src/util/var_resolver.h Normal file
View File

@@ -0,0 +1,57 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <string>
#include <map>
#include <set>
#include <vector>
namespace grex {
class VarResolver {
public:
// scan a string for ${var} patterns, return variable names found
static std::set<std::string> find_variables(const std::string& input);
// set a variable override (from UI)
void set(const std::string& name, const std::string& value);
// resolve all ${var} patterns in a string using:
// 1. user overrides (set via UI)
// 2. process environment (getenv)
// returns the string with variables expanded; unresolvable vars left as-is
std::string resolve(const std::string& input) const;
// check if all variables in a string can be resolved
bool can_resolve(const std::string& input) const;
// get all known variables and their values (empty string if unresolved)
const std::map<std::string, std::string>& overrides() const { return overrides_; }
// scan multiple strings, populate overrides_ with env values where available
void scan_and_populate(const std::vector<std::string>& inputs);
// get list of variable names that have no value
std::vector<std::string> unresolved() const;
private:
std::map<std::string, std::string> overrides_;
};
}

24765
src/vendor/nlohmann/json.hpp vendored Normal file

File diff suppressed because it is too large Load Diff

479
src/views/config_view.cpp Normal file
View File

@@ -0,0 +1,479 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/config_view.h"
#include "util/unsaved_dialog.h"
#include "util/json_helpers.h"
#include <cstdlib>
#include <filesystem>
namespace grex {
// binding structs to avoid commas in g_signal_connect macro args
struct CfgFieldBinding {
ConfigView* view;
std::string key;
};
struct CfgVarBinding {
ConfigView* view;
std::string var_name;
};
ConfigView::ConfigView(Project& project) : project_(project) {
root_ = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(root_), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_start(box, 16);
gtk_widget_set_margin_end(box, 16);
gtk_widget_set_margin_top(box, 16);
gtk_widget_set_margin_bottom(box, 16);
// === Config file label + Open/Close buttons ===
config_label_ = gtk_label_new(nullptr);
gtk_label_set_xalign(GTK_LABEL(config_label_), 0.0f);
gtk_widget_add_css_class(config_label_, "title-3");
gtk_box_append(GTK_BOX(box), config_label_);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
btn_open_ = gtk_button_new_with_label("Open Config");
btn_create_ = gtk_button_new_with_label("Create Config");
btn_close_ = gtk_button_new_with_label("Close Config");
gtk_widget_set_hexpand(btn_open_, TRUE);
gtk_widget_set_hexpand(btn_create_, TRUE);
gtk_widget_set_hexpand(btn_close_, TRUE);
gtk_box_append(GTK_BOX(btn_row), btn_open_);
gtk_box_append(GTK_BOX(btn_row), btn_create_);
gtk_box_append(GTK_BOX(btn_row), btn_close_);
gtk_box_append(GTK_BOX(box), btn_row);
g_signal_connect(btn_open_, "clicked", G_CALLBACK(on_open_config), this);
g_signal_connect(btn_create_, "clicked", G_CALLBACK(on_create_config), this);
g_signal_connect(btn_close_, "clicked", G_CALLBACK(on_close_config), this);
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
// === Config fields section ===
auto* config_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(config_header), "<b>Configuration</b>");
gtk_label_set_xalign(GTK_LABEL(config_header), 0.0f);
gtk_box_append(GTK_BOX(box), config_header);
config_grid_ = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(config_grid_), 8);
gtk_grid_set_column_spacing(GTK_GRID(config_grid_), 12);
gtk_box_append(GTK_BOX(box), config_grid_);
build_config_fields();
// === Resolved paths section ===
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* resolved_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(resolved_header), "<b>Resolved Paths</b>");
gtk_label_set_xalign(GTK_LABEL(resolved_header), 0.0f);
gtk_box_append(GTK_BOX(box), resolved_header);
resolved_grid_ = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(resolved_grid_), 4);
gtk_grid_set_column_spacing(GTK_GRID(resolved_grid_), 12);
gtk_box_append(GTK_BOX(box), resolved_grid_);
// === Variables section ===
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* vars_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(vars_header), "<b>Variables</b>");
gtk_label_set_xalign(GTK_LABEL(vars_header), 0.0f);
gtk_box_append(GTK_BOX(box), vars_header);
auto* vars_desc = gtk_label_new("Set values for variables found in config fields. Environment variables are used automatically.");
gtk_label_set_xalign(GTK_LABEL(vars_desc), 0.0f);
gtk_label_set_wrap(GTK_LABEL(vars_desc), TRUE);
gtk_box_append(GTK_BOX(box), vars_desc);
vars_grid_ = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(vars_grid_), 8);
gtk_grid_set_column_spacing(GTK_GRID(vars_grid_), 12);
gtk_box_append(GTK_BOX(box), vars_grid_);
build_variables_section();
update_resolved_labels();
// === Save button ===
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
btn_save_ = gtk_button_new_with_label("Save Config");
gtk_widget_set_halign(btn_save_, GTK_ALIGN_END);
gtk_box_append(GTK_BOX(box), btn_save_);
g_signal_connect(btn_save_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
static_cast<ConfigView*>(d)->apply_config();
}), this);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(root_), box);
update_config_buttons();
}
void ConfigView::build_config_fields() {
// free old bindings
for (auto* p : field_bindings_) delete static_cast<CfgFieldBinding*>(p);
field_bindings_.clear();
// clear grid
GtkWidget* child;
while ((child = gtk_widget_get_first_child(config_grid_)) != nullptr)
gtk_grid_remove(GTK_GRID(config_grid_), child);
// iterate all keys in the config JSON object
int row = 0;
for (auto& [key, val] : project_.config.data().items()) {
auto* label = gtk_label_new(key.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(config_grid_), label, 0, row, 1, 1);
auto* entry = gtk_entry_new();
if (val.is_string())
gtk_editable_set_text(GTK_EDITABLE(entry), val.get<std::string>().c_str());
else
gtk_editable_set_text(GTK_EDITABLE(entry), val.dump().c_str());
gtk_widget_set_hexpand(entry, TRUE);
gtk_grid_attach(GTK_GRID(config_grid_), entry, 1, row, 1, 1);
auto* binding = new CfgFieldBinding{this, key};
field_bindings_.push_back(binding);
g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* b = static_cast<CfgFieldBinding*>(d);
b->view->project_.config.set(b->key, gtk_editable_get_text(e));
// rescan all values for variables
b->view->project_.resolver.scan_and_populate(
b->view->project_.config.all_string_values());
b->view->rebuild_variables();
b->view->update_resolved_labels();
if (!b->view->rebuilding_) { b->view->dirty_ = true; gtk_widget_add_css_class(b->view->btn_save_, "suggested-action"); }
}), binding);
row++;
}
}
void ConfigView::rebuild_variables() {
build_variables_section();
}
void ConfigView::build_variables_section() {
rebuilding_ = true;
// free old bindings
for (auto* p : var_bindings_) delete static_cast<CfgVarBinding*>(p);
var_bindings_.clear();
GtkWidget* child;
while ((child = gtk_widget_get_first_child(vars_grid_)) != nullptr)
gtk_grid_remove(GTK_GRID(vars_grid_), child);
auto& overrides = project_.resolver.overrides();
if (overrides.empty()) {
auto* lbl = gtk_label_new("No variables found in config values.");
gtk_label_set_xalign(GTK_LABEL(lbl), 0.0f);
gtk_grid_attach(GTK_GRID(vars_grid_), lbl, 0, 0, 2, 1);
rebuilding_ = false;
return;
}
int row = 0;
for (auto& [name, value] : overrides) {
auto var_label = "${" + name + "}";
auto* lbl = gtk_label_new(var_label.c_str());
gtk_label_set_xalign(GTK_LABEL(lbl), 1.0f);
gtk_grid_attach(GTK_GRID(vars_grid_), lbl, 0, row, 1, 1);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
// populate from override first, then check live env
const char* env_val = std::getenv(name.c_str());
if (!value.empty()) {
gtk_editable_set_text(GTK_EDITABLE(entry), value.c_str());
} else if (env_val) {
gtk_editable_set_text(GTK_EDITABLE(entry), env_val);
// store the env value so resolver uses it
project_.resolver.set(name, env_val);
}
if (env_val) {
auto hint = std::string("from env: ") + env_val;
gtk_widget_set_tooltip_text(entry, hint.c_str());
}
gtk_grid_attach(GTK_GRID(vars_grid_), entry, 1, row, 1, 1);
auto* binding = new CfgVarBinding{this, name};
var_bindings_.push_back(binding);
g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* b = static_cast<CfgVarBinding*>(d);
if (b->view->rebuilding_) return;
b->view->project_.resolver.set(b->var_name, gtk_editable_get_text(e));
b->view->update_resolved_labels();
b->view->dirty_ = true;
gtk_widget_add_css_class(b->view->btn_save_, "suggested-action");
}), binding);
row++;
}
rebuilding_ = false;
}
void ConfigView::update_resolved_labels() {
// clear existing rows
GtkWidget* child;
while ((child = gtk_widget_get_first_child(resolved_grid_)) != nullptr)
gtk_grid_remove(GTK_GRID(resolved_grid_), child);
int row = 0;
auto root = project_.resolved_project_root();
for (auto& [key, val] : project_.config.data().items()) {
if (!val.is_string()) continue;
auto raw = val.get<std::string>();
bool has_vars = !VarResolver::find_variables(raw).empty();
bool is_path_key = key.find("path") != std::string::npos ||
key.find("root") != std::string::npos ||
key.find("dir") != std::string::npos;
if (!has_vars && !is_path_key) continue;
std::string display;
if (has_vars && !project_.resolver.can_resolve(raw)) {
display = "(unresolved)";
} else {
auto resolved = project_.resolver.resolve(raw);
// relative paths get combined with project root (except project_root itself)
if (!root.empty() && !resolved.empty() && resolved[0] != '/' && key != "project_root")
display = (root / resolved).string();
else
display = resolved;
}
auto* lbl = gtk_label_new(key.c_str());
gtk_label_set_xalign(GTK_LABEL(lbl), 1.0f);
gtk_grid_attach(GTK_GRID(resolved_grid_), lbl, 0, row, 1, 1);
auto* val_label = gtk_label_new(nullptr);
gtk_label_set_xalign(GTK_LABEL(val_label), 0.0f);
gtk_label_set_selectable(GTK_LABEL(val_label), TRUE);
gtk_widget_set_hexpand(val_label, TRUE);
if (display == "(unresolved)") {
gtk_label_set_markup(GTK_LABEL(val_label),
"<span foreground=\"red\">(unresolved)</span>");
} else if (std::filesystem::is_directory(display)) {
auto markup = "<span foreground=\"green\">" + display + "</span>";
gtk_label_set_markup(GTK_LABEL(val_label), markup.c_str());
} else if (std::filesystem::exists(display)) {
auto markup = "<span foreground=\"green\">" + display + "</span>";
gtk_label_set_markup(GTK_LABEL(val_label), markup.c_str());
} else {
auto markup = "<span foreground=\"red\">" + display + "</span>";
gtk_label_set_markup(GTK_LABEL(val_label), markup.c_str());
}
gtk_grid_attach(GTK_GRID(resolved_grid_), val_label, 1, row, 1, 1);
row++;
}
if (row == 0) {
auto* lbl = gtk_label_new("No resolvable paths in config.");
gtk_label_set_xalign(GTK_LABEL(lbl), 0.0f);
gtk_grid_attach(GTK_GRID(resolved_grid_), lbl, 0, 0, 2, 1);
}
}
void ConfigView::set_apply_callback(ApplyCallback cb, void* data) {
apply_cb_ = cb;
apply_cb_data_ = data;
}
void ConfigView::apply_config() {
namespace fs = std::filesystem;
try {
project_.config.save(project_.config_path);
dirty_ = false;
gtk_widget_remove_css_class(btn_save_, "suggested-action");
project_.report_status("Config saved: " + project_.config_path.filename().string());
// reload shells if path now resolves
auto sp = project_.resolved_shells_path();
if (!sp.empty() && fs::exists(sp)) {
project_.shells = ShellsFile::load(sp);
project_.report_status("Loaded shells: " + sp.filename().string());
} else if (!sp.empty()) {
project_.report_status("Error: shells path not found: " + sp.string());
}
// notify MainWindow to refresh other views
if (apply_cb_)
apply_cb_(apply_cb_data_);
} catch (const std::exception& e) {
project_.report_status(std::string("Error: apply failed: ") + e.what());
}
}
void ConfigView::update_config_buttons() {
bool has_config = !project_.config_path.empty();
if (has_config) {
auto markup = std::string("<b>Current Rex Config:</b> ") + project_.config_path.filename().string();
gtk_label_set_markup(GTK_LABEL(config_label_), markup.c_str());
} else {
gtk_label_set_markup(GTK_LABEL(config_label_), "<b>Current Rex Config:</b> No config loaded");
}
gtk_widget_set_visible(btn_open_, !has_config);
gtk_widget_set_visible(btn_create_, !has_config);
gtk_widget_set_visible(btn_close_, has_config);
}
void ConfigView::refresh() {
build_config_fields();
build_variables_section();
update_resolved_labels();
update_config_buttons();
dirty_ = false;
gtk_widget_remove_css_class(btn_save_, "suggested-action");
}
void ConfigView::on_open_config(GtkButton*, gpointer data) {
auto* self = static_cast<ConfigView*>(data);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Open Rex Config");
gtk_file_dialog_set_accept_label(dialog, "Select");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Config files (*.config)");
gtk_file_filter_add_pattern(filter, "*.config");
auto* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
g_list_store_append(filters, filter);
g_object_unref(filter);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
g_object_unref(filters);
gtk_file_dialog_open(dialog, window, nullptr, on_open_config_response, self);
}
void ConfigView::on_open_config_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<ConfigView*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), res, &error);
if (!file) {
if (error) g_error_free(error);
return;
}
auto* path = g_file_get_path(file);
g_object_unref(file);
try {
self->project_.open_config(path);
self->refresh();
if (self->apply_cb_)
self->apply_cb_(self->apply_cb_data_);
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error opening config: ") + e.what());
}
g_free(path);
}
void ConfigView::on_create_config(GtkButton*, gpointer data) {
auto* self = static_cast<ConfigView*>(data);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Create Rex Config");
gtk_file_dialog_set_initial_name(dialog, "rex.config");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Config files (*.config)");
gtk_file_filter_add_pattern(filter, "*.config");
auto* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
g_list_store_append(filters, filter);
g_object_unref(filter);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
g_object_unref(filters);
gtk_file_dialog_save(dialog, window, nullptr, on_create_config_response, self);
}
void ConfigView::on_create_config_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<ConfigView*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), res, &error);
if (!file) {
if (error) g_error_free(error);
return;
}
auto* path = g_file_get_path(file);
g_object_unref(file);
try {
// Write skeleton config
nlohmann::json j;
j["config"] = {
{"config_version", "5"},
{"project_root", ""},
{"units_path", "units"},
{"shells_path", ""},
{"logs_path", ""}
};
std::filesystem::path fp(path);
save_json_file(fp, j);
self->project_.open_config(fp);
self->refresh();
if (self->apply_cb_)
self->apply_cb_(self->apply_cb_data_);
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error creating config: ") + e.what());
}
g_free(path);
}
void ConfigView::on_close_config(GtkButton*, gpointer data) {
auto* self = static_cast<ConfigView*>(data);
if (self->dirty_) {
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Save)
self->apply_config();
}
self->project_.close_config();
self->refresh();
if (self->apply_cb_)
self->apply_cb_(self->apply_cb_data_);
}
}

72
src/views/config_view.h Normal file
View File

@@ -0,0 +1,72 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <vector>
#include "models/project.h"
namespace grex {
class ConfigView {
public:
explicit ConfigView(Project& project);
GtkWidget* widget() { return root_; }
void rebuild_variables();
void update_resolved_labels();
void apply_config();
void refresh();
bool is_dirty() const { return dirty_; }
using ApplyCallback = void(*)(void* data);
void set_apply_callback(ApplyCallback cb, void* data);
private:
Project& project_;
GtkWidget* root_;
GtkWidget* config_grid_;
GtkWidget* vars_grid_;
GtkWidget* resolved_grid_;
ApplyCallback apply_cb_ = nullptr;
void* apply_cb_data_ = nullptr;
bool rebuilding_ = false;
bool dirty_ = false;
std::vector<void*> field_bindings_; // CfgFieldBinding*
std::vector<void*> var_bindings_; // CfgVarBinding*
GtkWidget* config_label_;
GtkWidget* btn_open_;
GtkWidget* btn_create_;
GtkWidget* btn_close_;
GtkWidget* btn_save_;
void build_config_fields();
void build_variables_section();
void update_config_buttons();
static void on_open_config(GtkButton* btn, gpointer data);
static void on_open_config_response(GObject* source, GAsyncResult* res, gpointer data);
static void on_create_config(GtkButton* btn, gpointer data);
static void on_create_config_response(GObject* source, GAsyncResult* res, gpointer data);
static void on_close_config(GtkButton* btn, gpointer data);
};
}

298
src/views/main_window.cpp Normal file
View File

@@ -0,0 +1,298 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/main_window.h"
#include "views/config_view.h"
#include "views/plan_view.h"
#include "views/units_view.h"
#include "views/shells_view.h"
#include "util/unsaved_dialog.h"
namespace grex {
MainWindow::MainWindow(GtkApplication* app, Project& project, GrexConfig& grex_config)
: project_(project), grex_config_(grex_config) {
window_ = gtk_application_window_new(app);
gtk_window_set_title(GTK_WINDOW(window_), "grex");
gtk_window_set_default_size(GTK_WINDOW(window_), 1200, 800);
auto* vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
// Header bar
auto* header = gtk_header_bar_new();
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Rex Config");
gtk_window_set_titlebar(GTK_WINDOW(window_), header);
// Grex Config button in header bar (opens modal dialog)
auto* grex_btn = gtk_button_new_with_label("Grex Config");
gtk_widget_remove_css_class(grex_btn, "flat");
gtk_header_bar_pack_end(GTK_HEADER_BAR(header), grex_btn);
g_signal_connect(grex_btn, "clicked", G_CALLBACK(on_grex_config_clicked), this);
// Stack + switcher
stack_ = gtk_stack_new();
auto* stack = stack_;
gtk_stack_set_transition_type(GTK_STACK(stack), GTK_STACK_TRANSITION_TYPE_SLIDE_LEFT_RIGHT);
switcher_ = gtk_stack_switcher_new();
gtk_stack_switcher_set_stack(GTK_STACK_SWITCHER(switcher_), GTK_STACK(stack));
gtk_widget_set_halign(switcher_, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(vbox), switcher_);
// Wire project status reporting to status bar
project_.status_cb = on_project_status;
project_.status_cb_data = this;
// Rex Config view
config_view_ = new ConfigView(project_);
config_view_->set_apply_callback(on_config_applied, this);
gtk_stack_add_titled(GTK_STACK(stack), config_view_->widget(), "config", "Rex Config");
// Plan view
plan_view_ = new PlanView(project_, grex_config_);
gtk_stack_add_titled(GTK_STACK(stack), plan_view_->widget(), "plans", "Plans");
// Units view
units_view_ = new UnitsView(project_, grex_config_);
gtk_stack_add_titled(GTK_STACK(stack), units_view_->widget(), "units", "Units");
// Shells view
shells_view_ = new ShellsView(project_);
gtk_stack_add_titled(GTK_STACK(stack), shells_view_->widget(), "shells", "Shells");
gtk_widget_set_vexpand(stack, TRUE);
gtk_box_append(GTK_BOX(vbox), stack);
g_signal_connect(stack, "notify::visible-child", G_CALLBACK(on_stack_page_changed), this);
// Status bar
gtk_box_append(GTK_BOX(vbox), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* status_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_widget_set_size_request(status_bar, -1, 28);
status_label_ = gtk_label_new("Ready");
gtk_label_set_xalign(GTK_LABEL(status_label_), 0.0f);
gtk_label_set_ellipsize(GTK_LABEL(status_label_), PANGO_ELLIPSIZE_END);
gtk_label_set_selectable(GTK_LABEL(status_label_), TRUE);
gtk_widget_set_hexpand(status_label_, TRUE);
gtk_widget_set_margin_start(status_label_, 8);
gtk_widget_set_margin_end(status_label_, 8);
gtk_box_append(GTK_BOX(status_bar), status_label_);
gtk_box_append(GTK_BOX(vbox), status_bar);
prev_page_ = config_view_->widget();
gtk_window_set_child(GTK_WINDOW(window_), vbox);
update_tab_sensitivity();
}
MainWindow::~MainWindow() {
delete config_view_;
delete plan_view_;
delete units_view_;
delete shells_view_;
}
void MainWindow::set_status(const std::string& msg) {
gtk_label_set_text(GTK_LABEL(status_label_), msg.c_str());
}
void MainWindow::on_config_applied(void* data) {
auto* self = static_cast<MainWindow*>(data);
self->update_tab_sensitivity();
self->plan_view_->refresh();
self->units_view_->refresh();
self->shells_view_->refresh();
}
void MainWindow::update_tab_sensitivity() {
bool has_config = !project_.config_path.empty();
// Enable/disable the page content
gtk_widget_set_sensitive(plan_view_->widget(), has_config);
gtk_widget_set_sensitive(units_view_->widget(), has_config);
gtk_widget_set_sensitive(shells_view_->widget(), has_config);
// Grey out the switcher buttons for disabled tabs
// GtkStackSwitcher children are toggle buttons in page order
auto* child = gtk_widget_get_first_child(switcher_);
int i = 0;
while (child) {
// child 0 = Rex Config (always enabled), 1-3 = Plans/Units/Shells
if (i > 0)
gtk_widget_set_sensitive(child, has_config);
child = gtk_widget_get_next_sibling(child);
i++;
}
// If config was just closed and we're on a non-config tab, switch to config
if (!has_config) {
auto* visible = gtk_stack_get_visible_child(GTK_STACK(stack_));
if (visible != config_view_->widget())
gtk_stack_set_visible_child(GTK_STACK(stack_), config_view_->widget());
}
}
void MainWindow::on_stack_page_changed(GObject* stack, GParamSpec*, gpointer data) {
auto* self = static_cast<MainWindow*>(data);
auto* visible = gtk_stack_get_visible_child(GTK_STACK(stack));
// Check if previous page has unsaved changes
auto* prev = self->prev_page_;
self->prev_page_ = visible;
bool prev_dirty = false;
if (prev == self->config_view_->widget() && self->config_view_->is_dirty())
prev_dirty = true;
else if (prev == self->plan_view_->widget() && self->plan_view_->is_dirty())
prev_dirty = true;
else if (prev == self->units_view_->widget() && self->units_view_->is_dirty())
prev_dirty = true;
if (prev_dirty) {
auto result = show_unsaved_dialog(GTK_WINDOW(self->window_));
if (result == UnsavedResult::Save) {
if (self->config_view_->is_dirty())
self->config_view_->apply_config();
if (self->plan_view_->is_dirty())
self->plan_view_->save_dirty();
if (self->units_view_->is_dirty())
self->units_view_->save_current_file();
} else {
if (self->config_view_->is_dirty())
self->config_view_->refresh();
if (self->plan_view_->is_dirty())
self->plan_view_->revert_dirty();
if (self->units_view_->is_dirty())
self->units_view_->refresh();
}
}
// No dirty state — refresh immediately
self->refresh_visible_page();
}
void MainWindow::refresh_visible_page() {
auto* visible = gtk_stack_get_visible_child(GTK_STACK(stack_));
// Update window title for current tab
if (visible == config_view_->widget())
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Rex Config");
else if (visible == plan_view_->widget())
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Plans");
else if (visible == units_view_->widget())
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Units");
else if (visible == shells_view_->widget())
gtk_window_set_title(GTK_WINDOW(window_), "GREX: Shells");
// Refresh the newly visible page
if (visible == plan_view_->widget()) {
project_.load_all_units();
if (!project_.plans.empty()) {
auto& plan = project_.plans[0];
try {
plan = Plan::load(plan.filepath);
} catch (const std::exception& e) {
project_.report_status(std::string("Error reloading plan: ") + e.what());
}
}
plan_view_->refresh();
} else if (visible == units_view_->widget()) {
units_view_->refresh();
} else if (visible == shells_view_->widget()) {
project_.reload_shells();
shells_view_->refresh();
}
}
void MainWindow::on_project_status(const std::string& msg, void* data) {
auto* self = static_cast<MainWindow*>(data);
self->set_status(msg);
}
struct GCDialogData {
MainWindow* main;
GtkWidget* win;
GtkWidget* entry;
};
void MainWindow::on_grex_config_clicked(GtkButton*, gpointer data) {
auto* self = static_cast<MainWindow*>(data);
auto* win = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(win), "Grex Config");
gtk_window_set_transient_for(GTK_WINDOW(win), GTK_WINDOW(self->window_));
gtk_window_set_modal(GTK_WINDOW(win), TRUE);
gtk_window_set_default_size(GTK_WINDOW(win), 400, -1);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_start(box, 16);
gtk_widget_set_margin_end(box, 16);
gtk_widget_set_margin_top(box, 16);
gtk_widget_set_margin_bottom(box, 16);
auto* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 8);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
auto* label = gtk_label_new("File Editor");
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(grid), label, 0, 0, 1, 1);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_editable_set_text(GTK_EDITABLE(entry), self->grex_config_.file_editor.c_str());
gtk_grid_attach(GTK_GRID(grid), entry, 1, 0, 1, 1);
gtk_box_append(GTK_BOX(box), grid);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_cancel = gtk_button_new_with_label("Cancel");
auto* btn_save = gtk_button_new_with_label("Save");
gtk_box_append(GTK_BOX(btn_row), btn_cancel);
gtk_box_append(GTK_BOX(btn_row), btn_save);
gtk_box_append(GTK_BOX(box), btn_row);
gtk_window_set_child(GTK_WINDOW(win), box);
auto* dd = new GCDialogData{self, win, entry};
g_signal_connect(btn_cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* dd = static_cast<GCDialogData*>(d);
gtk_window_close(GTK_WINDOW(dd->win));
delete dd;
}), dd);
auto on_save = +[](GtkButton*, gpointer d) {
auto* dd = static_cast<GCDialogData*>(d);
dd->main->grex_config_.file_editor = gtk_editable_get_text(GTK_EDITABLE(dd->entry));
try {
dd->main->grex_config_.save();
dd->main->set_status("Saved grex config");
} catch (const std::exception& e) {
dd->main->set_status(std::string("Error saving grex config: ") + e.what());
}
gtk_window_close(GTK_WINDOW(dd->win));
delete dd;
};
g_signal_connect(btn_save, "clicked", G_CALLBACK(on_save), dd);
gtk_window_present(GTK_WINDOW(win));
}
}

61
src/views/main_window.h Normal file
View File

@@ -0,0 +1,61 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <string>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
class ConfigView;
class PlanView;
class UnitsView;
class ShellsView;
class MainWindow {
public:
MainWindow(GtkApplication* app, Project& project, GrexConfig& grex_config);
~MainWindow();
GtkWidget* widget() { return window_; }
void set_status(const std::string& msg);
private:
Project& project_;
GrexConfig& grex_config_;
GtkWidget* window_;
ConfigView* config_view_;
PlanView* plan_view_;
UnitsView* units_view_;
ShellsView* shells_view_;
GtkWidget* stack_;
GtkWidget* switcher_;
GtkWidget* status_label_;
GtkWidget* prev_page_ = nullptr;
void update_tab_sensitivity();
void refresh_visible_page();
static void on_config_applied(void* data);
static void on_stack_page_changed(GObject* stack, GParamSpec* pspec, gpointer data);
static void on_project_status(const std::string& msg, void* data);
static void on_grex_config_clicked(GtkButton* btn, gpointer data);
};
}

498
src/views/plan_view.cpp Normal file
View File

@@ -0,0 +1,498 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/plan_view.h"
#include "views/unit_editor.h"
#include "util/unsaved_dialog.h"
#include "util/unit_picker.h"
#include <algorithm>
#include <cstring>
namespace grex {
PlanView::PlanView(Project& project, GrexConfig& grex_config)
: project_(project), grex_config_(grex_config) {
root_ = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_paned_set_position(GTK_PANED(root_), 300);
// === Left panel ===
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_size_request(left, 200, -1);
// Plan label
plan_label_ = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(plan_label_), "<b>Plan:</b> No plan loaded");
gtk_label_set_xalign(GTK_LABEL(plan_label_), 0.0f);
gtk_widget_set_margin_start(plan_label_, 4);
gtk_widget_set_margin_end(plan_label_, 4);
gtk_widget_set_margin_top(plan_label_, 4);
gtk_widget_add_css_class(plan_label_, "title-3");
gtk_box_append(GTK_BOX(left), plan_label_);
// Plan management buttons (Open/Create shown when no plan, Close shown when plan loaded)
auto* mgmt_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(mgmt_row, 4);
gtk_widget_set_margin_end(mgmt_row, 4);
btn_open_plan_ = gtk_button_new_with_label("Open Plan");
btn_create_plan_ = gtk_button_new_with_label("Create Plan");
btn_close_plan_ = gtk_button_new_with_label("Close Plan");
gtk_widget_set_hexpand(btn_open_plan_, TRUE);
gtk_widget_set_hexpand(btn_create_plan_, TRUE);
gtk_widget_set_hexpand(btn_close_plan_, TRUE);
gtk_box_append(GTK_BOX(mgmt_row), btn_open_plan_);
gtk_box_append(GTK_BOX(mgmt_row), btn_create_plan_);
gtk_box_append(GTK_BOX(mgmt_row), btn_close_plan_);
gtk_box_append(GTK_BOX(left), mgmt_row);
// Task list
auto* scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
task_listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(task_listbox_), GTK_SELECTION_SINGLE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), task_listbox_);
gtk_box_append(GTK_BOX(left), scroll);
// Buttons
auto* btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(btn_box, 4);
gtk_widget_set_margin_end(btn_box, 4);
gtk_widget_set_margin_bottom(btn_box, 4);
auto* btn_add = gtk_button_new_with_label("Add Task");
auto* btn_del = gtk_button_new_with_label("Delete Task");
auto* btn_up = gtk_button_new_with_label("Up");
auto* btn_down = gtk_button_new_with_label("Down");
gtk_box_append(GTK_BOX(btn_box), btn_add);
gtk_box_append(GTK_BOX(btn_box), btn_del);
gtk_box_append(GTK_BOX(btn_box), btn_up);
gtk_box_append(GTK_BOX(btn_box), btn_down);
gtk_box_append(GTK_BOX(left), btn_box);
// Save Plan button
btn_save_plan_ = gtk_button_new_with_label("Save Plan");
gtk_widget_set_margin_start(btn_save_plan_, 4);
gtk_widget_set_margin_end(btn_save_plan_, 4);
gtk_widget_set_margin_bottom(btn_save_plan_, 4);
gtk_widget_set_hexpand(btn_save_plan_, TRUE);
gtk_box_append(GTK_BOX(left), btn_save_plan_);
gtk_paned_set_start_child(GTK_PANED(root_), left);
gtk_paned_set_shrink_start_child(GTK_PANED(root_), FALSE);
// === Right panel: Unit editor ===
unit_editor_ = new UnitEditor(project_, grex_config_);
gtk_paned_set_end_child(GTK_PANED(root_), unit_editor_->widget());
gtk_paned_set_shrink_end_child(GTK_PANED(root_), FALSE);
// Name change callback to refresh the task list row label
unit_editor_->set_name_changed_callback([](const std::string&, void* data) {
auto* self = static_cast<PlanView*>(data);
self->plan_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
if (self->current_task_idx_ >= 0)
self->refresh_task_row(self->current_task_idx_);
}, this);
// Signals
g_signal_connect(btn_open_plan_, "clicked", G_CALLBACK(on_open_plan), this);
g_signal_connect(btn_create_plan_, "clicked", G_CALLBACK(on_create_plan), this);
g_signal_connect(btn_close_plan_, "clicked", G_CALLBACK(on_close_plan), this);
g_signal_connect(task_listbox_, "row-selected", G_CALLBACK(on_task_selected), this);
g_signal_connect(btn_add, "clicked", G_CALLBACK(on_add_task), this);
g_signal_connect(btn_del, "clicked", G_CALLBACK(on_delete_task), this);
g_signal_connect(btn_up, "clicked", G_CALLBACK(on_move_up), this);
g_signal_connect(btn_down, "clicked", G_CALLBACK(on_move_down), this);
g_signal_connect(btn_save_plan_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<PlanView*>(d);
try {
self->project_.save_plans();
self->plan_dirty_ = false;
gtk_widget_remove_css_class(self->btn_save_plan_, "suggested-action");
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}), this);
update_plan_buttons();
}
Plan* PlanView::current_plan() {
if (project_.plans.empty())
return nullptr;
return &project_.plans[0];
}
void PlanView::populate_task_list() {
GtkWidget* child;
while ((child = gtk_widget_get_first_child(task_listbox_)) != nullptr)
gtk_list_box_remove(GTK_LIST_BOX(task_listbox_), child);
unit_editor_->clear();
current_task_idx_ = -1;
auto* plan = current_plan();
if (!plan) return;
for (auto& task : plan->tasks) {
auto* row = gtk_list_box_row_new();
auto text = std::string("\u25B6 ") + task.name;
auto* label = gtk_label_new(text.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 8);
gtk_widget_set_margin_end(label, 8);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
gtk_list_box_append(GTK_LIST_BOX(task_listbox_), row);
}
}
void PlanView::refresh_task_row(int idx) {
auto* plan = current_plan();
if (!plan || idx < 0 || idx >= (int)plan->tasks.size()) return;
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(task_listbox_), idx);
if (!row) return;
auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
if (GTK_IS_LABEL(label)) {
auto text = std::string("\u25B6 ") + plan->tasks[idx].name;
gtk_label_set_text(GTK_LABEL(label), text.c_str());
}
}
void PlanView::select_task(int idx) {
auto* plan = current_plan();
if (!plan || idx < 0 || idx >= (int)plan->tasks.size()) {
unit_editor_->clear();
return;
}
current_task_idx_ = idx;
auto& task = plan->tasks[idx];
// ensure units are loaded if paths resolve
if (project_.unit_files.empty())
project_.load_all_units();
Unit* unit = project_.find_unit(task.name);
unit_editor_->load(&task, unit);
}
// --- Open Plan ---
void PlanView::on_open_plan(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* window = gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW);
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Open Plan File");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Plan files (*.plan)");
gtk_file_filter_add_pattern(filter, "*.plan");
auto* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
g_list_store_append(filters, filter);
g_object_unref(filter);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
g_object_unref(filters);
gtk_file_dialog_open(dialog, GTK_WINDOW(window), nullptr, on_open_plan_response, self);
}
void PlanView::on_open_plan_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<PlanView*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), res, &error);
if (!file) {
if (error) g_error_free(error);
return;
}
auto* path = g_file_get_path(file);
g_object_unref(file);
try {
self->project_.load_plan(path);
auto* plan = self->current_plan();
if (plan)
gtk_label_set_markup(GTK_LABEL(self->plan_label_), (std::string("<b>Plan:</b> ") + plan->filepath.filename().string()).c_str());
self->populate_task_list();
self->update_plan_buttons();
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: failed to load plan: ") + e.what());
}
g_free(path);
}
// --- Task operations ---
void PlanView::on_task_selected(GtkListBox*, GtkListBoxRow* row, gpointer data) {
auto* self = static_cast<PlanView*>(data);
if (self->suppress_selection_) return;
if (!row) {
self->unit_editor_->clear();
self->current_task_idx_ = -1;
return;
}
int new_idx = gtk_list_box_row_get_index(row);
if (self->unit_editor_->is_dirty() && self->current_task_idx_ >= 0 && new_idx != self->current_task_idx_) {
// Re-select old row while dialog is showing
self->suppress_selection_ = true;
auto* old_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->task_listbox_), self->current_task_idx_);
if (old_row) gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), old_row);
self->suppress_selection_ = false;
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Save)
self->save_dirty();
else
self->revert_dirty();
self->suppress_selection_ = true;
auto* target_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->task_listbox_), new_idx);
if (target_row) gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), target_row);
self->suppress_selection_ = false;
self->select_task(new_idx);
return;
}
self->select_task(new_idx);
}
void PlanView::on_add_task(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* plan = self->current_plan();
if (!plan) return;
// ensure units are loaded
if (self->project_.unit_files.empty())
self->project_.load_all_units();
if (self->project_.unit_files.empty()) {
self->project_.report_status("Error: no units loaded");
return;
}
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
show_unit_picker(parent, self->project_, [self](const std::string& unit_name) {
auto* plan = self->current_plan();
if (plan) {
Task t;
t.name = unit_name;
t.dependencies = nlohmann::json::array({nullptr});
plan->tasks.push_back(t);
self->plan_dirty_ = true; gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
self->populate_task_list();
int last = (int)plan->tasks.size() - 1;
auto* task_row = gtk_list_box_get_row_at_index(
GTK_LIST_BOX(self->task_listbox_), last);
if (task_row)
gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), task_row);
}
});
}
void PlanView::on_delete_task(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* plan = self->current_plan();
if (!plan || self->current_task_idx_ < 0) return;
int idx = self->current_task_idx_;
plan->tasks.erase(plan->tasks.begin() + idx);
self->plan_dirty_ = true; gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
self->populate_task_list();
}
void PlanView::on_move_up(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* plan = self->current_plan();
if (!plan || self->current_task_idx_ <= 0) return;
int idx = self->current_task_idx_;
std::swap(plan->tasks[idx], plan->tasks[idx - 1]);
self->plan_dirty_ = true; gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
self->populate_task_list();
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->task_listbox_), idx - 1);
if (row)
gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), row);
}
void PlanView::on_move_down(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* plan = self->current_plan();
if (!plan || self->current_task_idx_ < 0 || self->current_task_idx_ >= (int)plan->tasks.size() - 1) return;
int idx = self->current_task_idx_;
std::swap(plan->tasks[idx], plan->tasks[idx + 1]);
self->plan_dirty_ = true; gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
self->populate_task_list();
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->task_listbox_), idx + 1);
if (row)
gtk_list_box_select_row(GTK_LIST_BOX(self->task_listbox_), row);
}
void PlanView::close_plan_impl() {
project_.plans.clear();
gtk_label_set_markup(GTK_LABEL(plan_label_), "<b>Plan:</b> No plan loaded");
populate_task_list();
update_plan_buttons();
plan_dirty_ = false;
gtk_widget_remove_css_class(btn_save_plan_, "suggested-action");
project_.report_status("Plan closed");
}
void PlanView::on_close_plan(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
if (self->is_dirty()) {
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Save)
self->save_dirty();
}
self->close_plan_impl();
}
void PlanView::on_create_plan(GtkButton*, gpointer data) {
auto* self = static_cast<PlanView*>(data);
auto* window = gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW);
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Create Plan File");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Plan files (*.plan)");
gtk_file_filter_add_pattern(filter, "*.plan");
auto* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
g_list_store_append(filters, filter);
g_object_unref(filter);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
g_object_unref(filters);
gtk_file_dialog_save(dialog, GTK_WINDOW(window), nullptr, on_create_plan_response, self);
}
void PlanView::on_create_plan_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<PlanView*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), res, &error);
if (!file) {
if (error) g_error_free(error);
return;
}
auto* path = g_file_get_path(file);
g_object_unref(file);
std::filesystem::path plan_path(path);
g_free(path);
// Ensure .plan extension
if (plan_path.extension() != ".plan")
plan_path += ".plan";
// Create a new empty plan
Plan plan;
plan.name = plan_path.stem().string();
plan.filepath = plan_path;
try {
plan.save();
self->project_.plans.clear();
self->project_.plans.push_back(std::move(plan));
auto* p = self->current_plan();
if (p)
gtk_label_set_markup(GTK_LABEL(self->plan_label_), (std::string("<b>Plan:</b> ") + p->filepath.filename().string()).c_str());
self->populate_task_list();
self->update_plan_buttons();
self->project_.report_status("Created plan: " + plan_path.filename().string());
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: failed to create plan: ") + e.what());
}
}
void PlanView::update_plan_buttons() {
bool has_plan = current_plan() != nullptr;
gtk_widget_set_visible(btn_open_plan_, !has_plan);
gtk_widget_set_visible(btn_create_plan_, !has_plan);
gtk_widget_set_visible(btn_close_plan_, has_plan);
}
void PlanView::refresh() {
// reload units if paths now resolve
project_.load_all_units();
auto* plan = current_plan();
if (plan)
gtk_label_set_markup(GTK_LABEL(plan_label_), (std::string("<b>Plan:</b> ") + plan->filepath.filename().string()).c_str());
else
gtk_label_set_markup(GTK_LABEL(plan_label_), "<b>Plan:</b> No plan loaded");
populate_task_list();
update_plan_buttons();
plan_dirty_ = false;
gtk_widget_remove_css_class(btn_save_plan_, "suggested-action");
}
bool PlanView::is_dirty() const {
return plan_dirty_ || unit_editor_->is_dirty();
}
void PlanView::save_dirty() {
if (unit_editor_->is_dirty())
unit_editor_->save_current();
if (plan_dirty_) {
try {
project_.save_plans();
plan_dirty_ = false;
gtk_widget_remove_css_class(btn_save_plan_, "suggested-action");
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
}
void PlanView::revert_dirty() {
if (unit_editor_->is_dirty())
unit_editor_->revert_current();
if (plan_dirty_ && !project_.plans.empty()) {
auto& plan = project_.plans[0];
try {
auto reloaded = Plan::load(plan.filepath);
plan = std::move(reloaded);
plan_dirty_ = false;
gtk_widget_remove_css_class(btn_save_plan_, "suggested-action");
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
}
}

74
src/views/plan_view.h Normal file
View File

@@ -0,0 +1,74 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
class UnitEditor;
class PlanView {
public:
PlanView(Project& project, GrexConfig& grex_config);
GtkWidget* widget() { return root_; }
void refresh();
bool is_dirty() const;
void save_dirty();
void revert_dirty();
private:
Project& project_;
GrexConfig& grex_config_;
GtkWidget* root_;
GtkWidget* plan_label_;
GtkWidget* task_listbox_;
GtkWidget* btn_open_plan_;
GtkWidget* btn_create_plan_;
GtkWidget* btn_close_plan_;
GtkWidget* btn_save_plan_;
UnitEditor* unit_editor_;
int current_task_idx_ = -1;
bool plan_dirty_ = false;
bool suppress_selection_ = false;
void populate_task_list();
void select_task(int idx);
Plan* current_plan();
static void on_open_plan(GtkButton* btn, gpointer data);
static void on_open_plan_response(GObject* source, GAsyncResult* res, gpointer data);
static void on_task_selected(GtkListBox* box, GtkListBoxRow* row, gpointer data);
static void on_add_task(GtkButton* btn, gpointer data);
static void on_delete_task(GtkButton* btn, gpointer data);
static void on_move_up(GtkButton* btn, gpointer data);
static void on_move_down(GtkButton* btn, gpointer data);
static void on_close_plan(GtkButton* btn, gpointer data);
static void on_create_plan(GtkButton* btn, gpointer data);
static void on_create_plan_response(GObject* source, GAsyncResult* res, gpointer data);
void refresh_task_row(int idx);
void update_plan_buttons();
void close_plan_impl();
};
}

210
src/views/shells_view.cpp Normal file
View File

@@ -0,0 +1,210 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/shells_view.h"
namespace grex {
ShellsView::ShellsView(Project& project) : project_(project), shells_(project.shells) {
root_ = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_paned_set_position(GTK_PANED(root_), 200);
// Left panel: shell list
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_size_request(left, 150, -1);
auto* scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(listbox_), GTK_SELECTION_SINGLE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), listbox_);
gtk_box_append(GTK_BOX(left), scroll);
auto* btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(btn_box, 4);
gtk_widget_set_margin_end(btn_box, 4);
gtk_widget_set_margin_bottom(btn_box, 4);
auto* btn_add = gtk_button_new_with_label("Add");
auto* btn_del = gtk_button_new_with_label("Delete");
auto* btn_save = gtk_button_new_with_label("Save Shells");
gtk_box_append(GTK_BOX(btn_box), btn_add);
gtk_box_append(GTK_BOX(btn_box), btn_del);
gtk_box_append(GTK_BOX(btn_box), btn_save);
gtk_box_append(GTK_BOX(left), btn_box);
gtk_paned_set_start_child(GTK_PANED(root_), left);
gtk_paned_set_shrink_start_child(GTK_PANED(root_), FALSE);
// Right panel: editor
auto* right_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(right_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
editor_grid_ = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(editor_grid_), 8);
gtk_grid_set_column_spacing(GTK_GRID(editor_grid_), 12);
gtk_widget_set_margin_start(editor_grid_, 16);
gtk_widget_set_margin_end(editor_grid_, 16);
gtk_widget_set_margin_top(editor_grid_, 16);
gtk_widget_set_margin_bottom(editor_grid_, 16);
auto make_row = [&](int row, const char* label_text) -> GtkWidget* {
auto* label = gtk_label_new(label_text);
gtk_label_set_xalign(GTK_LABEL(label), 1.0f);
gtk_grid_attach(GTK_GRID(editor_grid_), label, 0, row, 1, 1);
auto* entry = gtk_entry_new();
gtk_widget_set_hexpand(entry, TRUE);
gtk_grid_attach(GTK_GRID(editor_grid_), entry, 1, row, 1, 1);
return entry;
};
entry_name_ = make_row(0, "Name");
entry_path_ = make_row(1, "Path");
entry_exec_arg_ = make_row(2, "Execution Arg");
entry_source_cmd_ = make_row(3, "Source Cmd");
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(right_scroll), editor_grid_);
gtk_paned_set_end_child(GTK_PANED(root_), right_scroll);
gtk_paned_set_shrink_end_child(GTK_PANED(root_), FALSE);
// Signals
g_signal_connect(listbox_, "row-selected", G_CALLBACK(on_shell_selected), this);
g_signal_connect(btn_add, "clicked", G_CALLBACK(on_add_shell), this);
g_signal_connect(btn_del, "clicked", G_CALLBACK(on_delete_shell), this);
g_signal_connect(btn_save, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<ShellsView*>(d);
try {
self->project_.save_shells();
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}), this);
// Bind entry changes to model
auto bind = [this](GtkWidget* entry, int field) {
g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* self = static_cast<ShellsView*>(d);
if (self->loading_ || self->current_idx_ < 0 || self->current_idx_ >= (int)self->shells_.shells.size())
return;
auto& s = self->shells_.shells[self->current_idx_];
auto text = std::string(gtk_editable_get_text(e));
// determine which field by checking widget pointer
if (GTK_WIDGET(e) == self->entry_name_) {
s.name = text;
// refresh list row
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->listbox_), self->current_idx_);
if (row) {
auto* lbl = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
if (GTK_IS_LABEL(lbl)) gtk_label_set_text(GTK_LABEL(lbl), text.c_str());
}
} else if (GTK_WIDGET(e) == self->entry_path_) s.path = text;
else if (GTK_WIDGET(e) == self->entry_exec_arg_) s.execution_arg = text;
else if (GTK_WIDGET(e) == self->entry_source_cmd_) s.source_cmd = text;
}), this);
};
bind(entry_name_, 0);
bind(entry_path_, 1);
bind(entry_exec_arg_, 2);
bind(entry_source_cmd_, 3);
populate_list();
clear_editor();
}
void ShellsView::populate_list() {
GtkWidget* child;
while ((child = gtk_widget_get_first_child(listbox_)) != nullptr)
gtk_list_box_remove(GTK_LIST_BOX(listbox_), child);
for (auto& s : shells_.shells) {
auto* row = gtk_list_box_row_new();
auto stext = std::string("\u25B8 ") + s.name;
auto* label = gtk_label_new(stext.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 8);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
gtk_list_box_append(GTK_LIST_BOX(listbox_), row);
}
current_idx_ = -1;
clear_editor();
}
void ShellsView::clear_editor() {
loading_ = true;
gtk_editable_set_text(GTK_EDITABLE(entry_name_), "");
gtk_editable_set_text(GTK_EDITABLE(entry_path_), "");
gtk_editable_set_text(GTK_EDITABLE(entry_exec_arg_), "");
gtk_editable_set_text(GTK_EDITABLE(entry_source_cmd_), "");
gtk_widget_set_sensitive(editor_grid_, FALSE);
loading_ = false;
}
void ShellsView::load_shell(int idx) {
if (idx < 0 || idx >= (int)shells_.shells.size()) {
clear_editor();
return;
}
loading_ = true;
current_idx_ = idx;
auto& s = shells_.shells[idx];
gtk_widget_set_sensitive(editor_grid_, TRUE);
gtk_editable_set_text(GTK_EDITABLE(entry_name_), s.name.c_str());
gtk_editable_set_text(GTK_EDITABLE(entry_path_), s.path.c_str());
gtk_editable_set_text(GTK_EDITABLE(entry_exec_arg_), s.execution_arg.c_str());
gtk_editable_set_text(GTK_EDITABLE(entry_source_cmd_), s.source_cmd.c_str());
loading_ = false;
}
void ShellsView::on_shell_selected(GtkListBox*, GtkListBoxRow* row, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
if (!row) { self->clear_editor(); return; }
self->load_shell(gtk_list_box_row_get_index(row));
}
void ShellsView::on_add_shell(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
ShellDef s;
s.name = "new_shell";
s.path = "/usr/bin/new_shell";
s.execution_arg = "-c";
s.source_cmd = "source";
self->shells_.shells.push_back(s);
self->populate_list();
int last = (int)self->shells_.shells.size() - 1;
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->listbox_), last);
if (row)
gtk_list_box_select_row(GTK_LIST_BOX(self->listbox_), row);
}
void ShellsView::on_delete_shell(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data);
if (self->current_idx_ < 0) return;
self->shells_.shells.erase(self->shells_.shells.begin() + self->current_idx_);
self->populate_list();
}
void ShellsView::refresh() {
populate_list();
}
}

55
src/views/shells_view.h Normal file
View File

@@ -0,0 +1,55 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include "models/project.h"
namespace grex {
class ShellsView {
public:
explicit ShellsView(Project& project);
GtkWidget* widget() { return root_; }
void refresh();
private:
Project& project_;
ShellsFile& shells_;
GtkWidget* root_;
GtkWidget* listbox_;
GtkWidget* entry_name_;
GtkWidget* entry_path_;
GtkWidget* entry_exec_arg_;
GtkWidget* entry_source_cmd_;
GtkWidget* editor_grid_;
int current_idx_ = -1;
bool loading_ = false;
void populate_list();
void load_shell(int idx);
void clear_editor();
static void on_shell_selected(GtkListBox* box, GtkListBoxRow* row, gpointer data);
static void on_add_shell(GtkButton* btn, gpointer data);
static void on_delete_shell(GtkButton* btn, gpointer data);
};
}

225
src/views/unit_editor.cpp Normal file
View File

@@ -0,0 +1,225 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/unit_editor.h"
#include "util/unit_picker.h"
#include "util/unit_properties_dialog.h"
namespace grex {
UnitEditor::UnitEditor(Project& project, GrexConfig& grex_config)
: project_(project), grex_config_(grex_config) {
root_ = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(root_), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_start(box, 16);
gtk_widget_set_margin_end(box, 16);
gtk_widget_set_margin_top(box, 16);
gtk_widget_set_margin_bottom(box, 16);
// Task section header
auto* task_label = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(task_label), "<b>Task Properties</b>");
gtk_label_set_xalign(GTK_LABEL(task_label), 0.0f);
gtk_box_append(GTK_BOX(box), task_label);
auto* task_grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(task_grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(task_grid), 12);
// Name (read-only label)
auto* name_label = gtk_label_new("Name");
gtk_label_set_xalign(GTK_LABEL(name_label), 1.0f);
gtk_grid_attach(GTK_GRID(task_grid), name_label, 0, 0, 1, 1);
name_display_ = gtk_label_new("");
gtk_label_set_xalign(GTK_LABEL(name_display_), 0.0f);
gtk_widget_set_hexpand(name_display_, TRUE);
gtk_grid_attach(GTK_GRID(task_grid), name_display_, 1, 0, 1, 1);
// Comment
auto* comment_label = gtk_label_new("Comment");
gtk_label_set_xalign(GTK_LABEL(comment_label), 1.0f);
gtk_grid_attach(GTK_GRID(task_grid), comment_label, 0, 1, 1, 1);
entry_comment_ = gtk_entry_new();
gtk_widget_set_hexpand(entry_comment_, TRUE);
gtk_grid_attach(GTK_GRID(task_grid), entry_comment_, 1, 1, 1, 1);
// Change/Select Unit button — aligned with the value column
btn_select_unit_ = gtk_button_new_with_label("Change/Select Unit...");
gtk_widget_set_halign(btn_select_unit_, GTK_ALIGN_START);
g_signal_connect(btn_select_unit_, "clicked", G_CALLBACK(on_select_unit), this);
gtk_grid_attach(GTK_GRID(task_grid), btn_select_unit_, 1, 2, 1, 1);
gtk_box_append(GTK_BOX(box), task_grid);
// Buttons below task properties
gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
btn_edit_unit_ = gtk_button_new_with_label("Edit Unit...");
g_signal_connect(btn_edit_unit_, "clicked", G_CALLBACK(on_edit_unit), this);
gtk_box_append(GTK_BOX(btn_row), btn_edit_unit_);
btn_save_unit_ = gtk_button_new_with_label("Save Unit");
g_signal_connect(btn_save_unit_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<UnitEditor*>(d);
self->save_current();
}), this);
gtk_box_append(GTK_BOX(btn_row), btn_save_unit_);
gtk_box_append(GTK_BOX(box), btn_row);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(root_), box);
clear();
}
void UnitEditor::mark_dirty() {
dirty_ = true;
gtk_widget_add_css_class(btn_save_unit_, "suggested-action");
}
void UnitEditor::clear_dirty() {
dirty_ = false;
gtk_widget_remove_css_class(btn_save_unit_, "suggested-action");
}
void UnitEditor::clear() {
g_signal_handlers_disconnect_by_data(entry_comment_, this);
current_task_ = nullptr;
current_unit_ = nullptr;
current_unit_name_.clear();
gtk_label_set_text(GTK_LABEL(name_display_), "");
gtk_editable_set_text(GTK_EDITABLE(entry_comment_), "");
gtk_widget_set_sensitive(entry_comment_, FALSE);
gtk_widget_set_sensitive(btn_edit_unit_, FALSE);
clear_dirty();
}
void UnitEditor::load(Task* task, Unit* unit) {
g_signal_handlers_disconnect_by_data(entry_comment_, this);
current_task_ = task;
current_unit_ = unit;
current_unit_name_ = task ? task->name : "";
gtk_widget_set_sensitive(entry_comment_, TRUE);
if (unit) {
gtk_label_set_text(GTK_LABEL(name_display_), task->name.c_str());
} else {
auto markup = std::string("<span foreground=\"red\">") + task->name + "</span>";
gtk_label_set_markup(GTK_LABEL(name_display_), markup.c_str());
}
gtk_editable_set_text(GTK_EDITABLE(entry_comment_), task->comment.value_or("").c_str());
gtk_widget_set_sensitive(btn_edit_unit_, unit != nullptr);
// Connect comment change signal
g_signal_connect(entry_comment_, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* self = static_cast<UnitEditor*>(d);
if (!self->current_task_) return;
self->mark_dirty();
auto text = std::string(gtk_editable_get_text(e));
self->current_task_->comment = text.empty() ? std::nullopt : std::optional<std::string>(text);
}), this);
clear_dirty();
}
void UnitEditor::save_current() {
if (current_unit_name_.empty()) return;
auto* unit = project_.find_unit(current_unit_name_);
if (!unit) return;
current_unit_ = unit;
if (project_.is_unit_name_taken(current_unit_->name, current_unit_)) {
project_.report_status("Error: unit name '" + current_unit_->name + "' conflicts with another unit");
return;
}
auto* uf = project_.find_unit_file(current_unit_->name);
if (!uf) return;
try {
uf->save();
clear_dirty();
project_.report_status("Saved unit file: " + uf->filepath.filename().string());
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
void UnitEditor::revert_current() {
if (current_unit_name_.empty()) return;
auto* uf = project_.find_unit_file(current_unit_name_);
if (uf) {
try {
auto reloaded = UnitFile::load(uf->filepath);
*uf = std::move(reloaded);
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
if (!project_.plans.empty()) {
auto& plan = project_.plans[0];
try {
auto reloaded = Plan::load(plan.filepath);
plan = std::move(reloaded);
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
clear_dirty();
}
void UnitEditor::set_name_changed_callback(NameChangedCallback cb, void* data) {
name_cb_ = cb;
name_cb_data_ = data;
}
void UnitEditor::on_edit_unit(GtkButton*, gpointer data) {
auto* self = static_cast<UnitEditor*>(data);
if (!self->current_unit_) return;
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unit_properties_dialog(parent, self->current_unit_,
self->project_, self->grex_config_, self->project_.shells.shells);
if (result == UnitDialogResult::Save)
self->mark_dirty();
}
void UnitEditor::on_select_unit(GtkButton*, gpointer data) {
auto* self = static_cast<UnitEditor*>(data);
if (!self->current_task_) return;
if (self->project_.unit_files.empty())
self->project_.load_all_units();
if (self->project_.unit_files.empty()) {
self->project_.report_status("Error: no units loaded");
return;
}
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
show_unit_picker(parent, self->project_, [self](const std::string& unit_name) {
if (self->current_task_) self->current_task_->name = unit_name;
Unit* unit = self->project_.find_unit(unit_name);
self->load(self->current_task_, unit);
if (self->name_cb_) self->name_cb_(unit_name, self->name_cb_data_);
});
}
}

69
src/views/unit_editor.h Normal file
View File

@@ -0,0 +1,69 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include <string>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
class UnitEditor {
public:
UnitEditor(Project& project, GrexConfig& grex_config);
~UnitEditor() = default;
GtkWidget* widget() { return root_; }
void load(Task* task, Unit* unit);
void clear();
bool is_dirty() const { return dirty_; }
void mark_dirty();
void clear_dirty();
void save_current();
void revert_current();
using NameChangedCallback = void(*)(const std::string& new_name, void* data);
void set_name_changed_callback(NameChangedCallback cb, void* data);
private:
Project& project_;
GrexConfig& grex_config_;
Task* current_task_ = nullptr;
Unit* current_unit_ = nullptr;
std::string current_unit_name_;
GtkWidget* root_;
// task fields
GtkWidget* name_display_;
GtkWidget* btn_select_unit_;
GtkWidget* btn_edit_unit_;
GtkWidget* btn_save_unit_;
GtkWidget* entry_comment_;
NameChangedCallback name_cb_ = nullptr;
void* name_cb_data_ = nullptr;
bool dirty_ = false;
static void on_select_unit(GtkButton* btn, gpointer data);
static void on_edit_unit(GtkButton* btn, gpointer data);
};
}

655
src/views/units_view.cpp Normal file
View File

@@ -0,0 +1,655 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "views/units_view.h"
#include "util/unsaved_dialog.h"
#include "util/unit_properties_dialog.h"
#include <cstring>
#include <fstream>
static int sort_listbox_alpha(GtkListBoxRow* a, GtkListBoxRow* b, gpointer) {
auto* la = GTK_LABEL(gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(a)));
auto* lb = GTK_LABEL(gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(b)));
return std::strcmp(gtk_label_get_text(la), gtk_label_get_text(lb));
}
namespace grex {
UnitsView::UnitsView(Project& project, GrexConfig& grex_config)
: project_(project), grex_config_(grex_config) {
root_ = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_paned_set_position(GTK_PANED(root_), 300);
// === Left panel: unit files ===
auto* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_size_request(left, 200, -1);
auto* file_header = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(file_header), "<b>Unit Files</b>");
gtk_label_set_xalign(GTK_LABEL(file_header), 0.0f);
gtk_widget_set_margin_start(file_header, 4);
gtk_widget_set_margin_end(file_header, 4);
gtk_widget_set_margin_top(file_header, 4);
gtk_widget_add_css_class(file_header, "title-3");
gtk_box_append(GTK_BOX(left), file_header);
auto* file_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(file_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(file_scroll, TRUE);
file_listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(file_listbox_), GTK_SELECTION_SINGLE);
gtk_list_box_set_sort_func(GTK_LIST_BOX(file_listbox_), sort_listbox_alpha, nullptr, nullptr);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(file_scroll), file_listbox_);
gtk_box_append(GTK_BOX(left), file_scroll);
auto* file_btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(file_btn_box, 4);
gtk_widget_set_margin_end(file_btn_box, 4);
gtk_widget_set_margin_bottom(file_btn_box, 4);
auto* btn_new_file = gtk_button_new_with_label("New File");
auto* btn_del_file = gtk_button_new_with_label("Delete File");
gtk_box_append(GTK_BOX(file_btn_box), btn_new_file);
gtk_box_append(GTK_BOX(file_btn_box), btn_del_file);
gtk_box_append(GTK_BOX(left), file_btn_box);
gtk_paned_set_start_child(GTK_PANED(root_), left);
gtk_paned_set_shrink_start_child(GTK_PANED(root_), FALSE);
// === Right panel: units in selected file ===
auto* right = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
file_label_ = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(file_label_), "<b>No file selected</b>");
gtk_label_set_xalign(GTK_LABEL(file_label_), 0.0f);
gtk_widget_set_margin_start(file_label_, 4);
gtk_widget_set_margin_end(file_label_, 4);
gtk_widget_set_margin_top(file_label_, 4);
gtk_widget_add_css_class(file_label_, "title-3");
gtk_box_append(GTK_BOX(right), file_label_);
auto* unit_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(unit_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(unit_scroll, TRUE);
unit_listbox_ = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(unit_listbox_), GTK_SELECTION_SINGLE);
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(unit_listbox_), FALSE);
// No sort — units appear in file order
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(unit_scroll), unit_listbox_);
gtk_box_append(GTK_BOX(right), unit_scroll);
auto* unit_btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_start(unit_btn_box, 4);
gtk_widget_set_margin_end(unit_btn_box, 4);
gtk_widget_set_margin_bottom(unit_btn_box, 4);
auto* btn_new_unit = gtk_button_new_with_label("New Unit");
auto* btn_del_unit = gtk_button_new_with_label("Delete Unit");
auto* btn_edit_unit = gtk_button_new_with_label("Edit Unit...");
auto* btn_move_up = gtk_button_new_with_label("Move Up");
auto* btn_move_down = gtk_button_new_with_label("Move Down");
gtk_box_append(GTK_BOX(unit_btn_box), btn_new_unit);
gtk_box_append(GTK_BOX(unit_btn_box), btn_del_unit);
gtk_box_append(GTK_BOX(unit_btn_box), btn_edit_unit);
gtk_box_append(GTK_BOX(unit_btn_box), btn_move_up);
gtk_box_append(GTK_BOX(unit_btn_box), btn_move_down);
gtk_box_append(GTK_BOX(right), unit_btn_box);
// Save button
btn_save_ = gtk_button_new_with_label("Save Unit File");
gtk_widget_set_margin_start(btn_save_, 4);
gtk_widget_set_margin_end(btn_save_, 4);
gtk_widget_set_margin_bottom(btn_save_, 4);
gtk_widget_set_hexpand(btn_save_, TRUE);
gtk_box_append(GTK_BOX(right), btn_save_);
gtk_paned_set_end_child(GTK_PANED(root_), right);
gtk_paned_set_shrink_end_child(GTK_PANED(root_), FALSE);
// Signals
g_signal_connect(file_listbox_, "row-selected", G_CALLBACK(on_file_selected), this);
g_signal_connect(btn_new_file, "clicked", G_CALLBACK(on_new_file), this);
g_signal_connect(btn_del_file, "clicked", G_CALLBACK(on_delete_file), this);
g_signal_connect(btn_new_unit, "clicked", G_CALLBACK(on_new_unit), this);
g_signal_connect(btn_del_unit, "clicked", G_CALLBACK(on_delete_unit), this);
g_signal_connect(btn_edit_unit, "clicked", G_CALLBACK(on_edit_unit), this);
g_signal_connect(btn_move_up, "clicked", G_CALLBACK(on_move_up), this);
g_signal_connect(btn_move_down, "clicked", G_CALLBACK(on_move_down), this);
g_signal_connect(unit_listbox_, "row-activated", G_CALLBACK(on_unit_activated), this);
g_signal_connect(btn_save_, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<UnitsView*>(d);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size()) {
self->project_.report_status("Error: no unit file selected");
return;
}
auto& uf = self->project_.unit_files[self->current_file_idx_];
// Check for cross-file duplicates before saving
for (auto& u : uf.units) {
if (self->project_.is_unit_name_taken(u.name, &u)) {
self->project_.report_status("Error: unit '" + u.name + "' conflicts with a unit in another file");
return;
}
}
try {
uf.save();
self->file_dirty_ = false;
gtk_widget_remove_css_class(self->btn_save_, "suggested-action");
self->project_.report_status("Saved unit file: " + uf.filepath.filename().string());
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}), this);
}
void UnitsView::populate_file_list() {
GtkWidget* child;
while ((child = gtk_widget_get_first_child(file_listbox_)) != nullptr)
gtk_list_box_remove(GTK_LIST_BOX(file_listbox_), child);
current_file_idx_ = -1;
for (auto& uf : project_.unit_files) {
auto* row = gtk_list_box_row_new();
auto text = std::string("\u25C6 ") + uf.filepath.filename().string();
auto* label = gtk_label_new(text.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 8);
gtk_widget_set_margin_end(label, 8);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
gtk_list_box_append(GTK_LIST_BOX(file_listbox_), row);
}
populate_unit_list();
}
void UnitsView::populate_unit_list() {
GtkWidget* child;
while ((child = gtk_widget_get_first_child(unit_listbox_)) != nullptr)
gtk_list_box_remove(GTK_LIST_BOX(unit_listbox_), child);
if (current_file_idx_ < 0 || current_file_idx_ >= (int)project_.unit_files.size()) {
gtk_label_set_markup(GTK_LABEL(file_label_), "<b>No file selected</b>");
return;
}
auto& uf = project_.unit_files[current_file_idx_];
gtk_label_set_markup(GTK_LABEL(file_label_),
(std::string("<b>") + uf.filepath.filename().string() + "</b>").c_str());
for (auto& u : uf.units) {
auto* row = gtk_list_box_row_new();
auto text = std::string("\u2022 ") + u.name;
auto* label = gtk_label_new(text.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 8);
gtk_widget_set_margin_end(label, 8);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
gtk_list_box_append(GTK_LIST_BOX(unit_listbox_), row);
}
}
void UnitsView::refresh() {
project_.load_all_units();
populate_file_list();
}
void UnitsView::save_current_file() {
if (current_file_idx_ < 0 || current_file_idx_ >= (int)project_.unit_files.size())
return;
auto& uf = project_.unit_files[current_file_idx_];
try {
uf.save();
file_dirty_ = false;
gtk_widget_remove_css_class(btn_save_, "suggested-action");
project_.report_status("Saved unit file: " + uf.filepath.filename().string());
} catch (const std::exception& e) {
project_.report_status(std::string("Error: ") + e.what());
}
}
void UnitsView::on_file_selected(GtkListBox*, GtkListBoxRow* row, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->suppress_selection_) return;
if (!row) {
self->current_file_idx_ = -1;
self->populate_unit_list();
return;
}
int new_idx = gtk_list_box_row_get_index(row);
if (self->file_dirty_ && self->current_file_idx_ >= 0 && new_idx != self->current_file_idx_) {
int old_idx = self->current_file_idx_;
self->suppress_selection_ = true;
auto* old_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->file_listbox_), old_idx);
if (old_row) gtk_list_box_select_row(GTK_LIST_BOX(self->file_listbox_), old_row);
self->suppress_selection_ = false;
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unsaved_dialog(parent);
if (result == UnsavedResult::Save) {
auto& uf = self->project_.unit_files[self->current_file_idx_];
try { uf.save(); } catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
} else {
auto idx = self->current_file_idx_;
if (idx >= 0 && idx < (int)self->project_.unit_files.size()) {
auto& uf = self->project_.unit_files[idx];
try { uf = UnitFile::load(uf.filepath); } catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}
}
self->file_dirty_ = false; gtk_widget_remove_css_class(self->btn_save_, "suggested-action");
self->suppress_selection_ = true;
auto* target_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->file_listbox_), new_idx);
if (target_row) gtk_list_box_select_row(GTK_LIST_BOX(self->file_listbox_), target_row);
self->suppress_selection_ = false;
self->current_file_idx_ = new_idx;
self->populate_unit_list();
return;
}
self->current_file_idx_ = new_idx;
self->file_dirty_ = false; gtk_widget_remove_css_class(self->btn_save_, "suggested-action");
self->populate_unit_list();
}
void UnitsView::on_new_file(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
auto* window = gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW);
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Create Unit File");
gtk_file_dialog_set_accept_label(dialog, "Create");
auto* filter = gtk_file_filter_new();
gtk_file_filter_set_name(filter, "Unit files (*.units)");
gtk_file_filter_add_pattern(filter, "*.units");
auto* filters = g_list_store_new(GTK_TYPE_FILE_FILTER);
g_list_store_append(filters, filter);
g_object_unref(filter);
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
g_object_unref(filters);
auto units_dir = self->project_.resolved_units_dir();
if (!units_dir.empty()) {
auto* initial = g_file_new_for_path(units_dir.c_str());
gtk_file_dialog_set_initial_folder(dialog, initial);
g_object_unref(initial);
}
gtk_file_dialog_save(dialog, GTK_WINDOW(window), nullptr, on_new_file_response, self);
}
void UnitsView::on_new_file_response(GObject* source, GAsyncResult* res, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(source), res, &error);
if (!file) {
if (error) g_error_free(error);
return;
}
auto* path = g_file_get_path(file);
g_object_unref(file);
std::filesystem::path fp(path);
g_free(path);
if (fp.extension() != ".units")
fp += ".units";
// Create empty unit file
UnitFile uf;
uf.name = fp.stem().string();
uf.filepath = fp;
try {
uf.save();
self->project_.unit_files.push_back(std::move(uf));
self->populate_file_list();
// Select the new file
int last = (int)self->project_.unit_files.size() - 1;
auto* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->file_listbox_), last);
if (row)
gtk_list_box_select_row(GTK_LIST_BOX(self->file_listbox_), row);
self->project_.report_status("Created unit file: " + fp.filename().string());
} catch (const std::exception& e) {
self->project_.report_status(std::string("Error: ") + e.what());
}
}
void UnitsView::on_delete_file(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size())
return;
auto& uf = self->project_.unit_files[self->current_file_idx_];
auto name = uf.filepath.filename().string();
self->project_.unit_files.erase(self->project_.unit_files.begin() + self->current_file_idx_);
self->current_file_idx_ = -1;
self->populate_file_list();
self->project_.report_status("Removed unit file from project: " + name + " (file not deleted from disk)");
}
void UnitsView::on_new_unit(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size()) {
self->project_.report_status("Error: select a unit file first");
return;
}
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto* win = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(win), "New Unit");
gtk_window_set_transient_for(GTK_WINDOW(win), parent);
gtk_window_set_modal(GTK_WINDOW(win), TRUE);
gtk_window_set_default_size(GTK_WINDOW(win), 350, -1);
auto* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_start(box, 16);
gtk_widget_set_margin_end(box, 16);
gtk_widget_set_margin_top(box, 16);
gtk_widget_set_margin_bottom(box, 16);
auto* header = gtk_label_new("Enter a name for the new unit...");
gtk_label_set_xalign(GTK_LABEL(header), 0.0f);
gtk_box_append(GTK_BOX(box), header);
auto* entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "unit_name");
gtk_widget_set_hexpand(entry, TRUE);
gtk_box_append(GTK_BOX(box), entry);
auto* btn_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_halign(btn_row, GTK_ALIGN_END);
auto* btn_cancel = gtk_button_new_with_label("Cancel");
auto* btn_create = gtk_button_new_with_label("Create");
gtk_box_append(GTK_BOX(btn_row), btn_cancel);
gtk_box_append(GTK_BOX(btn_row), btn_create);
gtk_box_append(GTK_BOX(box), btn_row);
gtk_window_set_child(GTK_WINDOW(win), box);
struct NewUnitData {
UnitsView* view;
GtkWidget* win;
GtkWidget* entry;
};
auto* nd = new NewUnitData{self, win, entry};
g_signal_connect(btn_cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* nd = static_cast<NewUnitData*>(d);
gtk_window_close(GTK_WINDOW(nd->win));
delete nd;
}), nd);
auto on_create = +[](GtkButton*, gpointer d) {
auto* nd = static_cast<NewUnitData*>(d);
auto name = std::string(gtk_editable_get_text(GTK_EDITABLE(nd->entry)));
if (name.empty()) {
nd->view->project_.report_status("Error: unit name cannot be empty");
return;
}
// Check for duplicate across all unit files
if (nd->view->project_.is_unit_name_taken(name)) {
nd->view->project_.report_status("Error: unit '" + name + "' already exists");
return;
}
auto& uf = nd->view->project_.unit_files[nd->view->current_file_idx_];
Unit u;
u.name = name;
uf.units.push_back(u);
nd->view->populate_unit_list();
nd->view->file_dirty_ = true; gtk_widget_add_css_class(nd->view->btn_save_, "suggested-action");
nd->view->project_.report_status("Created unit: " + name);
gtk_window_close(GTK_WINDOW(nd->win));
delete nd;
};
g_signal_connect(btn_create, "clicked", G_CALLBACK(on_create), nd);
gtk_window_present(GTK_WINDOW(win));
}
void UnitsView::on_delete_unit(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size())
return;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->unit_listbox_));
if (!row) return;
int idx = gtk_list_box_row_get_index(row);
auto& uf = self->project_.unit_files[self->current_file_idx_];
if (idx < 0 || idx >= (int)uf.units.size()) return;
auto name = uf.units[idx].name;
uf.units.erase(uf.units.begin() + idx);
self->populate_unit_list();
self->file_dirty_ = true; gtk_widget_add_css_class(self->btn_save_, "suggested-action");
self->project_.report_status("Deleted unit: " + name);
}
void UnitsView::cancel_rename() {
if (!rename_active_) return;
rename_active_ = false;
// Restore original label
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(rename_row_), rename_old_label_);
g_object_unref(rename_old_label_);
rename_old_label_ = nullptr;
rename_row_ = nullptr;
rename_unit_idx_ = -1;
}
void UnitsView::finish_rename(const std::string& new_name) {
if (!rename_active_) return;
rename_active_ = false;
auto& uf = project_.unit_files[current_file_idx_];
auto* unit = &uf.units[rename_unit_idx_];
if (!new_name.empty() && new_name != unit->name) {
if (project_.is_unit_name_taken(new_name, unit)) {
project_.report_status("Error: unit '" + new_name + "' already exists");
} else {
auto old = unit->name;
unit->name = new_name;
file_dirty_ = true;
gtk_widget_add_css_class(btn_save_, "suggested-action");
project_.report_status("Renamed unit: " + old + " -> " + new_name);
}
}
// Update label text to current name and restore it
auto text = std::string("\u2022 ") + uf.units[rename_unit_idx_].name;
gtk_label_set_text(GTK_LABEL(rename_old_label_), text.c_str());
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(rename_row_), rename_old_label_);
g_object_unref(rename_old_label_);
rename_old_label_ = nullptr;
rename_row_ = nullptr;
rename_unit_idx_ = -1;
}
void UnitsView::on_unit_activated(GtkListBox*, GtkListBoxRow* row, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size())
return;
// Cancel any in-progress rename first
self->cancel_rename();
auto& uf = self->project_.unit_files[self->current_file_idx_];
// Get unit name from the row's label (strip bullet prefix)
auto* old_label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
auto label_text = std::string(gtk_label_get_text(GTK_LABEL(old_label)));
if (label_text.rfind("\u2022 ", 0) == 0)
label_text = label_text.substr(strlen("\u2022 "));
// Find the actual index in the units vector by name
int idx = -1;
for (int i = 0; i < (int)uf.units.size(); i++) {
if (uf.units[i].name == label_text) { idx = i; break; }
}
if (idx < 0) return;
g_object_ref(old_label);
self->rename_active_ = true;
self->rename_row_ = row;
self->rename_old_label_ = old_label;
self->rename_unit_idx_ = idx;
auto* entry = gtk_entry_new();
gtk_editable_set_text(GTK_EDITABLE(entry), uf.units[idx].name.c_str());
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), entry);
// Commit on Enter
g_signal_connect(entry, "activate", G_CALLBACK(+[](GtkEntry* e, gpointer d) {
auto* self = static_cast<UnitsView*>(d);
self->finish_rename(gtk_editable_get_text(GTK_EDITABLE(e)));
}), self);
// Cancel on focus loss — deferred to idle to avoid re-entrancy during selection
auto* focus_controller = gtk_event_controller_focus_new();
g_signal_connect(focus_controller, "leave", G_CALLBACK(+[](GtkEventControllerFocus*, gpointer d) {
g_idle_add(+[](gpointer d) -> gboolean {
auto* self = static_cast<UnitsView*>(d);
self->cancel_rename();
return G_SOURCE_REMOVE;
}, d);
}), self);
gtk_widget_add_controller(entry, focus_controller);
gtk_widget_grab_focus(entry);
}
void UnitsView::on_move_up(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size())
return;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->unit_listbox_));
if (!row) return;
auto& uf = self->project_.unit_files[self->current_file_idx_];
// Find unit index by name from the row label
auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
auto label_text = std::string(gtk_label_get_text(GTK_LABEL(label)));
if (label_text.rfind("\u2022 ", 0) == 0)
label_text = label_text.substr(strlen("\u2022 "));
int idx = -1;
for (int i = 0; i < (int)uf.units.size(); i++) {
if (uf.units[i].name == label_text) { idx = i; break; }
}
if (idx <= 0) return;
std::swap(uf.units[idx], uf.units[idx - 1]);
self->file_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_, "suggested-action");
self->populate_unit_list();
// Re-select the moved unit
auto* new_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->unit_listbox_), idx - 1);
if (new_row) gtk_list_box_select_row(GTK_LIST_BOX(self->unit_listbox_), new_row);
}
void UnitsView::on_move_down(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size())
return;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->unit_listbox_));
if (!row) return;
auto& uf = self->project_.unit_files[self->current_file_idx_];
// Find unit index by name from the row label
auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
auto label_text = std::string(gtk_label_get_text(GTK_LABEL(label)));
if (label_text.rfind("\u2022 ", 0) == 0)
label_text = label_text.substr(strlen("\u2022 "));
int idx = -1;
for (int i = 0; i < (int)uf.units.size(); i++) {
if (uf.units[i].name == label_text) { idx = i; break; }
}
if (idx < 0 || idx >= (int)uf.units.size() - 1) return;
std::swap(uf.units[idx], uf.units[idx + 1]);
self->file_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_, "suggested-action");
self->populate_unit_list();
// Re-select the moved unit
auto* new_row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(self->unit_listbox_), idx + 1);
if (new_row) gtk_list_box_select_row(GTK_LIST_BOX(self->unit_listbox_), new_row);
}
void UnitsView::on_edit_unit(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(data);
if (self->current_file_idx_ < 0 || self->current_file_idx_ >= (int)self->project_.unit_files.size())
return;
auto* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(self->unit_listbox_));
if (!row) {
self->project_.report_status("Error: select a unit first");
return;
}
// Get unit name from row label (strip bullet prefix)
auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
auto label_text = std::string(gtk_label_get_text(GTK_LABEL(label)));
if (label_text.rfind("\u2022 ", 0) == 0)
label_text = label_text.substr(strlen("\u2022 "));
// Find unit by name
auto& uf = self->project_.unit_files[self->current_file_idx_];
Unit* unit = nullptr;
for (auto& u : uf.units) {
if (u.name == label_text) { unit = &u; break; }
}
if (!unit) return;
auto* parent = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto result = show_unit_properties_dialog(parent, unit,
self->project_, self->grex_config_, self->project_.shells.shells);
if (result == UnitDialogResult::Save) {
self->file_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_, "suggested-action");
}
}
}

69
src/views/units_view.h Normal file
View File

@@ -0,0 +1,69 @@
/*
GREX - A graphical frontend for creating and managing Rex project files.
Copyright (C) 2026 SILO GROUP, LLC. Written by Chris Punches
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <gtk/gtk.h>
#include "models/project.h"
#include "models/grex_config.h"
namespace grex {
class UnitsView {
public:
UnitsView(Project& project, GrexConfig& grex_config);
GtkWidget* widget() { return root_; }
void refresh();
bool is_dirty() const { return file_dirty_; }
void save_current_file();
private:
Project& project_;
GrexConfig& grex_config_;
GtkWidget* root_;
GtkWidget* file_listbox_;
GtkWidget* unit_listbox_;
GtkWidget* file_label_;
GtkWidget* btn_save_;
int current_file_idx_ = -1;
bool file_dirty_ = false;
bool suppress_selection_ = false;
void populate_file_list();
void populate_unit_list();
static void on_file_selected(GtkListBox* box, GtkListBoxRow* row, gpointer data);
static void on_new_file(GtkButton* btn, gpointer data);
static void on_new_file_response(GObject* source, GAsyncResult* res, gpointer data);
static void on_delete_file(GtkButton* btn, gpointer data);
static void on_new_unit(GtkButton* btn, gpointer data);
static void on_delete_unit(GtkButton* btn, gpointer data);
static void on_unit_activated(GtkListBox* box, GtkListBoxRow* row, gpointer data);
static void on_edit_unit(GtkButton* btn, gpointer data);
static void on_move_up(GtkButton* btn, gpointer data);
static void on_move_down(GtkButton* btn, gpointer data);
void cancel_rename();
void finish_rename(const std::string& new_name);
GtkListBoxRow* rename_row_ = nullptr;
GtkWidget* rename_old_label_ = nullptr;
int rename_unit_idx_ = -1;
bool rename_active_ = false;
};
}