ArthurSonzogni / FTXUI

:computer: C++ Functional Terminal User Interface. :heart:
MIT License
6.73k stars 401 forks source link

Feature request: Support multi-paragraph text layout #247

Closed Zjl37 closed 2 years ago

Zjl37 commented 2 years ago

I was enjoying the amazing library when I got crazy to find out that...

There is no proper way I can make two paragraphs (with word wrap) adjacent!

I've tried... this: ```cpp auto si = ScreenInteractive::Fullscreen(); auto ui = Renderer([]() { return vbox({ text("... title goes here ..."), hflow(paragraph(" To celebrate Dragon Boat Festival, an underwater dance performance titled Pray was aired on Henan TV last Saturday night, the first day of the holiday.")), hflow(paragraph(" Featuring the goddess of Luo River – a mysterious beauty best known in the poetry of Cao Zhi during the Three Kingdoms period, the dancer Haohao He, a former synchronized swimmer, recreated the elegance of this ancient Chinese goddess.")), }); }); si.Loop(ui); ``` ... which spares the vertical space evenly for the two paragraphs, and this: ```cpp auto ui = Container::Vertical({ Renderer([]() { return text("... title goes here ..."); }), Renderer([]() { return vbox({ hflow(paragraph(" To celebrate Dragon Boat Festival, an underwater dance performance titled Pray was aired on Henan TV last Saturday night, the first day of the holiday.")), hflow(paragraph(" Featuring the goddess of Luo River – a mysterious beauty best known in the poetry of Cao Zhi during the Three Kingdoms period, the dancer Haohao He, a former synchronized swimmer, recreated the elegance of this ancient Chinese goddess.")), }); }) }); ``` ... where paragraphs collapse and only one line is visible, with a weird `g` at the top left corner: ``` g.. title goes here ... To celebrate Dragon Boat Festival, an underwater dance performance titled Featuring the goddess of Luo River – a mysterious beauty best known in the ```

And I notices this: https://github.com/ArthurSonzogni/FTXUI/issues/68#issuecomment-762807087

So FTXUI is missing an important and very basic feature for displaying longer texts, and the solution seems not clear.

I think a discussion about solution is needed here.

ArthurSonzogni commented 2 years ago

Thanks for using FTXUI! Yes, this is a known issue. Suggestions would be welcome!

The layout algorithm is very simple: https://github.com/ArthurSonzogni/FTXUI/blob/master/include/ftxui/dom/node.hpp

  1. Propagate bottom-up: Each nodes compute what they need (width, height, flexibility)
    // Step 1: Compute layout requirement. Tell parent what dimensions this
    //         element wants to be.
    //         Propagated from Children to Parents.
    virtual void ComputeRequirement();
    Requirement requirement() { return requirement_; }
  2. Propate top-down the assigned final dimensions:
    // Step 2: Assign this element its final dimensions.
    //         Propagated from Parents to Children.
    virtual void SetBox(Box box);

This works well for the flexbox layout: hbox, vbox.

Then, I experimented with hflow. The difficulty here is that computing the requirements. The required height (computed on phase 1), depends on the assigned width (computed in phase 2). This doesn't really fit with the model, because the dependencies are the other way around.

See https://github.com/ArthurSonzogni/FTXUI/blob/master/src/ftxui/dom/hflow.cpp#L18

As a result, hflow only ask for 1x1 pixel + flexibility. We hope developers will reserve them enough height using size for the element to be displayed fully.

  void ComputeRequirement() override {
    requirement_.min_x = 1;
    requirement_.min_y = 1;
    requirement_.flex_grow_x = 1;
    requirement_.flex_grow_y = 1;
    requirement_.flex_shrink_x = 0;
    requirement_.flex_shrink_y = 0;
    for (auto& child : children_)
      child->ComputeRequirement();
  }

Would you have ideas how to modify the current algorithm to make flow layout to work better?

Maybe we can run:

  1. ComputeRequirement() (return 1x1 + signal there are some hflow layout)
  2. SetBox (get (assigned_width_1, assigned_height_1) (Then if there are some hflow layout:)
  3. ComputeRequirement (return (assigned_width_1, computed_height)
  4. SetBox (get (assigned_width_2, assigned_height))
Zjl37 commented 2 years ago

@ArthurSonzogni Thanks for the explanation!

  1. My first thought is that, since some elements' height depends on its width, width requirement and height requirement should be calculated separately. If a flexbox contains an element with such need, it should run something like ComputeRequirementX() -> SetWidth() -> ComputeRequirementY() -> SetHeight().

However, this brought new problem when a flexbox contains both height-depends-on-width element and width-depends-on-height element. I don't know how to handle this.

  1. I have little experience. Maybe we should see how web technology solves such problem, but I can't find relevant information.
Zjl37 commented 2 years ago

Can you add label help wanted and try to invite more people to discussion? I have limited time and feels helpless.

ArthurSonzogni commented 2 years ago

I took ~2 weeks of reflection and I am now ready to build something for supporting this feature. Wish me luck ;-)

ArthurSonzogni commented 2 years ago

Day 1: We are starting to see something! Here is an example with several complicated flow layout within vertical and horizontal flex box:

https://user-images.githubusercontent.com/4759106/141368881-3d570e6e-045e-4239-9fe6-45a6c91ba343.mp4

ArthurSonzogni commented 2 years ago

Another one:

https://user-images.githubusercontent.com/4759106/141370632-03bc9970-efbd-4990-ae7e-7ff6a4847627.mp4

Zjl37 commented 2 years ago

Exciting to see that!

Actually I also looked into the code and tried to think of a practical and detailed approach. But as I also want to take "width-depends-on-height" elements into account (say, we might have "vflow" in the future), I found it very difficult.

Can you explain what changes you have currently made to the layout system?

ArthurSonzogni commented 2 years ago

Thanks!

The problem with the "with-depends-on-height" algorithm above are:

Here is my WIP patch: https://github.com/ArthurSonzogni/FTXUI/commit/8197591a2d6afef900e41862c701800c9fadf4e1

What I did was defining a new optional signal going through the elements, indicating the current iteration and asking if a new iteration is needed. I used a struct, in order to be extendable in the future, without breaking the API.

  // Layout may not resolve within a single iteration for some elements. This
  // allows them to request additionnal iterations. This signal must be
  // forwarded to children at least once.
  struct Status {
    int iteration = 0;
    bool need_iteration = false;
  };
  virtual void Check(Status* status);

Default implementation is:

void Node::Check(Status* status) {
  for (auto& child : children_)
    child->Check(status);
  status->need_iteration |= (status->iteration == 0);
}

Which propagate the signal to every element, and return it needs one iteration of the layout algorithm.

The Render algorithm become:

/// @brief Display an element on a ftxui::Screen.
/// @ingroup dom
void Render(Screen& screen, Node* node) {
  Box box;
  box.x_min = 0;
  box.y_min = 0;
  box.x_max = screen.dimx() - 1;
  box.y_max = screen.dimy() - 1;

  Node::Status status;
  node->Check(&status);
  while (status.need_iteration && status.iteration < 20) {
    // Step 1: Find what dimension this elements wants to be.
    node->ComputeRequirement();

    // Step 2: Assign a dimension to the element.
    node->SetBox(box);

    // Check if the element needs another iteration of the layout algorithm.
    status.need_iteration = false;
    status.iteration++;
    node->Check(&status);
  }

  // Step 3: Draw the element.
  screen.stencil = box;
  node->Render(screen);

  // Step 4: Apply shaders
  screen.ApplyShader();
}

The, we have to provide an alternative implementation for the hflow element. At iteration zero, it is configured with dim_x_to_ask = infinity. In ComputeRequirement, it returns width=dim_x_to_ask and height computed given its width. In SetBox, it reduces dim_x_to_ask if given less than asked and starts a new iteration.

There are still a lot of work for convincing me this is good enough. I will have to work on some tests.

Zjl37 commented 2 years ago

Hmm, after looking though your implementation, I found it quite different to what I thought, and I doubt that multiple iteration be reliable.

I would like to post my solution here for a reference:

My solution

EDITED

  1. A new variable should be added to struct Requirement to indicate whether the node's "height is depend on width" and its "width is depend on height", let's call it "dimension dependency" for now, call the values "HDW" and "WDH" for short.
  2. methods of class Node -- virtual void ComputeRequirement() and virtual void SetBox(Box box); is broken into the following several methods: (the name not sure yet, and the actual way to implement may differ)
    • virtual void ComputeRequirementBegin(), to compute "dimension dependency", propagated bottom-up.
    • virtual void ComputeDimension(<** an enum value that can be COMPUTE_X, COMPUTE_Y or COMPUTE_XY **>)
    • virtual void SetDimension(std::optional<int> x, std::optional<int> y)
    • virtual void ComputeRequirementEnd(), to compute selection and selected box.
  3. Render procedure: consequently call the root element's ComputeRequirementBegin(), ComputeDimension(COMPUTE_XY), SetDimension(<both x and y>), ComputeRequirementEnd().
  4. Behavior of flow elements:
    • They set a dimension dependency flag when ComputeRequirementBegin called.
    • On ComputeDimension called for the first time, they ask for a size when no wrap occurs (as if there is infinite space in the flow direction). This size is usually very big, and is useful later.
    • It does not flex-grow, but only flex-shrink in the flow direction. This way we can make use of the existing shrink size calculation.
  5. What flexbox do on SetDimension(x, y):
    • For vbox, if HDW is set, first call all HDW children's SetDimension(x, std::nullopt_t), then call their ComputeDimension(COMPUTE_Y), then subtract these required height (and fixed size children's height) from y, take it as the remaining vertical space. Most probably this remaining vertical space is not enough for other WDH and (HDW | WDH) children(, if any), so flex-shrink happens, and dispatch the remaining vertical space to these children (proportionally, according to the first time requirement). Call SetDimension(x, <disy>) on them. As for those HDW children, their height is usually what they required.
    • hbox should do similarly.
  6. It is guaranteed that SetDimension(x, std::nullopt_t) will only be called on HDW elements (will not be called on WDH or (HDW | WDH) elements), and SetDimension(std::nullopt_t, y) will only be called on WDH elements.
  7. Overall, this is a big change. All Node derived class will need rewrite.
Zjl37 commented 2 years ago

question

I noticed that, in the second video you posted, the nested hbox's width is always about twice as the remaining space in that row, which I'm aware that previous version can't do (, and my solution can't do either). (I mean the nested hbox and the paragraph left to it are just two elements of a hbox. Normally when both's flex-grow is set, they should have 1:1 width.) Can you explain this? Is this some side effect?

ArthurSonzogni commented 2 years ago

Thanks @Zjl37 for the feedback!

Indeed, what I prototyped yesterday is still a prototype. Definitively not ready yet.

Maybe I can learn from Chrome's implementation for new ideas.

I took a look at your proposition. I don't understand fully for now. I will try taking more time to get it. I think it would help if you could show on a simple example how this works recursively.


question

I noticed that, in the second video you posted, the nested hbox's width is always about twice as the remaining space in that row, which I'm aware that previous version can't do (, and my solution can't do either). (I mean the nested hbox and the paragraph left to it are just two elements of a hbox. Normally when both's flex-grow is set, they should have 1:1 width.) Can you explain this? Is this some side effect?

It is the side effect of the current algoritm implementing vbox and hbox. See: https://github.com/ArthurSonzogni/FTXUI/blob/master/src/ftxui/dom/box_helper.cpp

Note that there are two kind of flexibility. The flexibility to shrink and the flexibility to grow.

When the size we got is lower than what we asked to render the full hbox's children, and dispatching the negative space into shrinkable children won't be enough, we need to dispatch the negative space into normal elements. We don't take any flex coefficients into account, because they aren't shrinkable, we just remove from what they asked the negative space proportionally to what they require.

In our case when the children are hflow elements, they asked at iteration=0 width=infinity on the left and the groups of elements on the right asked right 2×`infinity. As a result, we dispatch proportionally the negative space on the two sides. As a results, they conserve the 1/3 2/3 ratios.

// Called when the size allowed is lower than the requested size, and the
// shrinkable element can not absorbe the (negative) extra_space. This assign
// zero to shrinkable elements and distribute the remaining (negative)
// extra_space toward the other non shrinkable elements.
void ComputeShrinkHard(std::vector<Element>* elements,
                       int extra_space,
                       int size) {
  for (Element& element : *elements) {
    if (element.flex_shrink) {
      element.size = 0;
      continue;
    }

    int added_space = extra_space * element.min_size / std::max(1, size);
    extra_space -= added_space;
    size -= element.min_size;

    element.size = element.min_size + added_space;
  }
}
ArthurSonzogni commented 2 years ago

One interesting video: https://www.youtube.com/watch?v=SyUBuio_ooE&t=1592s

Zjl37 commented 2 years ago

Hi @ArthurSonzogni , thanks for the explanation on size calculation. Now I understand. In fact, I'm not familiar with web, and when I went reading CSS flexbox tutorials, I found some similarity: the required width in first iteration in your prototype is like flex-basis in CSS, except that in CSS it's not set to infinity for paragraphs, but instead max-content size(, as suggested in this MDN document).

The size calculation is not a key point here, but we may keep an eye for it when evaluating solutions.


Indeed, what I prototyped yesterday is still a prototype. Definitively not ready yet.

I see.

ArthurSonzogni commented 2 years ago

I would like to support vertical flow and a few other variations. Here is a tentative API to describe this:

Element flow(Elements, FlowConfig)
struct FlowConfig {
  enum Direction {
    Row,
    RowInversed,
    Column,
    ColumnInversed,
  };

  enum Wrap {
    NoWrap,
    Wrap,
    WrapInversed,
  }

  enum Align {
    Start,
    Center,
    End
    Stretch,
    SpaceBetween,
    SpaceAround,
    SpaceEvenly,
  }

  Direction direction = Row;
  Wrap wrap = Wrap;
  Align align_main_axis = Start;
  Align align_cross_axis = Start;
  int gap_main_axis = 0;
  int gap_cross_axis = 0;

  // Constructor pattern. For use like:
  // ```
  // FlowConfig()
  //    .SetDirection(FlowConfig::Row)
  //    .SetWrap(FlowConfig::Wrap);
  // ```
  FlowConfig& SetDirection(FlowConfig::Direction direction);
  FlowConfig& SetWrap(FlowConfig::Wrap wrap);
  FlowConfig& SetAlignMainAxis(FlowConfig::Align align);
  FlowConfig& SetAlignCrossAxis(FlowConfig::Align align);
  FlowConfig& SetGapMainAxis(int gap);
  FlowConfig& SetGapCrossAxis(int gap);
}
Zjl37 commented 2 years ago

I just modified and edited my proposition, which looks clearer and more practical. Please have a look again.

ArthurSonzogni commented 2 years ago

Sorry for this long week without any update...

I worked on implementing all the possible flexbox layout. Having a way to test both height-depends-on-width and width-depends-on-height is going to be very helpful building test and selecting the right algorithm.

FlowConfig::Direction::Row:

https://user-images.githubusercontent.com/4759106/142773234-641eb35d-279a-40b3-922c-af09dc4cc026.mp4

FlowConfig::Direction::RowInversed:

https://user-images.githubusercontent.com/4759106/142773245-cbf9fdb8-0da0-40ba-9966-6b47ff7966ff.mp4

FlowConfig::Direction::Column:

https://user-images.githubusercontent.com/4759106/142773252-bad4b52e-7cee-47d7-8993-0b09620bec47.mp4

FlowConfig::Direction::ColumnsInversed:

https://user-images.githubusercontent.com/4759106/142773248-e60adf13-31b5-4bf3-b732-4d8249981138.mp4

ArthurSonzogni commented 2 years ago

I made a component to test the different flexbox options: WIP:

https://user-images.githubusercontent.com/4759106/142777071-f21b2a6b-b845-46be-bee6-ba1ae747f3e0.mp4

ArthurSonzogni commented 2 years ago

Some new videos. I will soon be able to publish it.

https://user-images.githubusercontent.com/4759106/145106956-7f360ad2-d948-40f6-ad61-962a650e17b8.mp4

https://user-images.githubusercontent.com/4759106/145107473-af45759b-99dd-4710-8a3c-66b5cd257221.mp4

ArthurSonzogni commented 2 years ago

@Zjl37, I landed the flexbox change.

This brings the following elements:

This is a breaking change and it will be included inside version 2.0

Could you please let's give it a try and give your opinions?

ArthurSonzogni commented 2 years ago

(Marking this as fixed)

Zjl37 commented 2 years ago

Hi @ArthurSonzogni , it's exciting to see the feature landed!

From the user's view, the change is great and inspiring, everything working as expected, and I'll of course happily use it. However, as I have studied the code, I'm mostly concerned about the core changes to Node -- the many-iteration approach:

  1. In some cases, the current implementation unnecessarily slows down the size calculation process, which could have took only 2 iterations in theory if we know more information. More specifically, this happens when

    • there is no enough space for all flexbox content at all.
    • row flexbox and column flexbox inside the same flexbox.
  2. The "many iteration" approach hides the real demand of an element, which sounds like a defective design. Its intent is not clear at first sight. I mean when someone wants to implement a new kind of element, they might write not functioning code more easily. And that passing Node::Status to children's Check by pointer -- what if they change int iteration?
    If this is for extensibility, I can't think of any other circumstances that needs more than 2 iterations.


In all, this change is good enough, but looks not perfect to me. If there are other thoughts behind this choice, or you can convince me this is better, please tell me. I am actually willing to wait a longer time for a more perfect solution.

ArthurSonzogni commented 2 years ago

image

I agree this is not ideal.

This can even give some unexpected results when using nested flexbox.

I should aim for better before releasing 2.0. However I am not sure how. I know there is the solution you kindly provided in: https://github.com/ArthurSonzogni/FTXUI/issues/247#issuecomment-967122836 but I can't really convince it works or won't, and I am afraid breaking existing users to much.

Maybe I should ask directly Chromium engineer, or try to learn from other C++ projects like: https://yogalayout.com/

The main algorith is: https://github.com/facebook/yoga/blob/main/yoga/Yoga.cpp

At first glance, it looks a bit complex to understand. This is a ~4300 algorithm. Mine is 14× shorter. Maybe I can learn something useful from this.

Zjl37 commented 2 years ago

and I am afraid breaking existing users to much.

I see.

May I ask what is a version 2.0? We're now in 0.11, are we jumping across major version 1?

ArthurSonzogni commented 2 years ago

May I ask what is a version 2.0? We're now in 0.11, are we jumping across major version 1?

The flexbox change modified the behavior of hbox and paragraph. Updating FTXUI will break folks using it. So I have to move from 0.11 to 1.0

(Yes, I said 2.0 wrongly)